Resumable.js Integration (pause, resume, retry & chunked uploads)

This commit is contained in:
Ryan
2025-03-23 02:13:10 -04:00
committed by GitHub
parent 6d588eb143
commit a9c7bb6493
9 changed files with 776 additions and 257 deletions

View File

@@ -15,10 +15,13 @@ MFE - Multi File Upload Editor is a lightweight, secure, self-hosted web applica
## Features
- **Multiple File/Folder Uploads with Progress:**
- Users can select and upload multiple files & folders at once.
- Each file upload displays an individual progress bar with percentage and upload speed.
- Image files show a small thumbnail preview (with default Material icons for other file types).
- **Multiple File/Folder Uploads with Progress (Resumable.js Integration):**
- Users can effortlessly upload multiple files and folders simultaneously by either selecting them through the file picker or dragging and dropping them directly into the interface.
- **Chunked Uploads:** Files are uploaded in configurable chunks (default set as 3 MB) to efficiently handle large files.
- **Pause, Resume, and Retry:** Uploads can be paused and resumed at any time, with support for retrying failed chunks.
- **Real-Time Progress:** Each file shows an individual progress bar that displays percentage complete and upload speed.
- **File & Folder Grouping:** When many files are dropped, files are automatically grouped into a scrollable wrapper, ensuring the interface remains clean.
- **Secure Uploads:** All uploads integrate CSRF token validation and other security checks.
- **Built-in File Editing & Renaming:**
- Text-based files (e.g., .txt, .html, .js) can be opened and edited in a modal window using CodeMirror for:
- Syntax highlighting
@@ -40,11 +43,12 @@ MFE - Multi File Upload Editor is a lightweight, secure, self-hosted web applica
- **Copy Files:** Copy selected files to another folder with a unique-naming feature to prevent overwrites.
- **Move Files:** Move selected files to a different folder, automatically generating a unique filename if needed to avoid data loss.
- **Download Files as ZIP:** Download selected files as a ZIP archive. Users can specify a custom name for the ZIP file via a modal dialog.
- **Drag & Drop:** Easily move files by selecting them from the file list and simply dragging them onto your desired folder in the folder tree. When you drop the files onto a folder, the system automatically moves them, updating your file organization in one seamless action.
- **Drag & Drop:** Easily move files by selecting them from the file list and simply dragging them onto your desired folder in the folder tree or breadcrumb. When you drop the files onto a folder, the system automatically moves them, updating your file organization in one seamless action.
- **Folder Management:**
- Organize files into folders and subfolders with the ability to create, rename, and delete folders.
- A dynamic folder tree in the UI allows users to navigate directories easily, and any changes are immediately reflected in real time.
- **Per-Folder Metadata Storage:** Each folder has its own metadata JSON file (e.g., `root_metadata.json`, `FolderName_metadata.json`), and operations (copy/move/rename) update these metadata files accordingly.
- **Intuitive Breadcrumb Navigation:** Clickable breadcrumbs enable users to quickly jump to any parent folder, streamlining navigation across subfolders. Supports drag & drop to move files.
- **Sorting & Pagination:**
- The file list can be sorted by name, modified date, upload date, file size, or uploader.
- Pagination controls let users navigate through files with selectable page sizes (10, 20, 50, or 100 items per page) and “Prev”/“Next” navigation buttons.

View File

@@ -172,7 +172,7 @@ function enhancedPreviewFile(fileUrl, fileName) {
modal.innerHTML = `
<div class="modal-content image-preview-modal-content" style="position: relative; max-width: 90vw; max-height: 90vh;">
<span id="closeFileModal" class="close-image-modal" style="position: absolute; top: 10px; right: 10px; font-size: 24px; cursor: pointer;">&times;</span>
<h4 class="image-modal-header" style="text-align: center; margin-top: 40px;"></h4>
<h4 class="image-modal-header"></h4>
<div class="file-preview-container" style="position: relative; text-align: center;"></div>
</div>`;
document.body.appendChild(modal);
@@ -1007,7 +1007,7 @@ function adjustEditorSize() {
if (modal && window.currentEditor) {
// Calculate available height for the editor.
// If you have a header or footer inside the modal, subtract their heights.
const headerHeight = 60;
const headerHeight = 60; // adjust this value as needed
const availableHeight = modal.clientHeight - headerHeight;
window.currentEditor.setSize("100%", availableHeight + "px");
}
@@ -1054,12 +1054,12 @@ export function editFile(fileName, folder) {
modal.innerHTML = `
<div class="editor-header">
<h3 class="editor-title">Editing: ${fileName}</h3>
<div class="editor-controls">
<button id="decreaseFont" class="btn btn-sm btn-secondary">A-</button>
<button id="increaseFont" class="btn btn-sm btn-secondary">A+</button>
</div>
<button id="closeEditorX" class="editor-close-btn">&times;</button>
</div>
<div id="editorControls" class="editor-controls">
<button id="decreaseFont" class="btn btn-sm btn-secondary">A-</button>
<button id="increaseFont" class="btn btn-sm btn-secondary">A+</button>
</div>
<textarea id="fileEditor" class="editor-textarea">${content}</textarea>
<div class="editor-footer">
<button id="saveBtn" class="btn btn-primary">Save</button>
@@ -1157,13 +1157,17 @@ export function saveFile(fileName, folder) {
}
export function displayFilePreview(file, container) {
// Use the underlying File object if it exists (for resumable files)
const actualFile = file.file || file;
container.style.display = "inline-block";
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(actualFile.name)) {
const img = document.createElement("img");
img.src = URL.createObjectURL(file);
img.src = URL.createObjectURL(actualFile);
img.classList.add("file-preview-img");
container.innerHTML = ""; // Clear previous content
container.appendChild(img);
} else {
container.innerHTML = ""; // Clear previous content
const iconSpan = document.createElement("span");
iconSpan.classList.add("material-icons", "file-icon");
iconSpan.textContent = "insert_drive_file";

View File

@@ -87,9 +87,10 @@ function bindBreadcrumbEvents() {
const breadcrumbLinks = document.querySelectorAll(".breadcrumb-link");
breadcrumbLinks.forEach(link => {
// Click event for navigation.
link.addEventListener("click", function(e) {
link.addEventListener("click", function (e) {
e.stopPropagation();
let folder = this.getAttribute("data-folder");
console.log("Breadcrumb clicked, folder:", folder);
window.currentFolder = folder;
localStorage.setItem("lastOpenedFolder", folder);
const titleEl = document.getElementById("fileListTitle");
@@ -113,14 +114,14 @@ function bindBreadcrumbEvents() {
});
// Drag-and-drop events.
link.addEventListener("dragover", function(e) {
link.addEventListener("dragover", function (e) {
e.preventDefault();
this.classList.add("drop-hover");
});
link.addEventListener("dragleave", function(e) {
link.addEventListener("dragleave", function (e) {
this.classList.remove("drop-hover");
});
link.addEventListener("drop", function(e) {
link.addEventListener("drop", function (e) {
e.preventDefault();
this.classList.remove("drop-hover");
const dropFolder = this.getAttribute("data-folder");
@@ -365,7 +366,7 @@ export async function loadFolderTree(selectedFolder) {
// Event binding for folder selection in folder tree.
container.querySelectorAll(".folder-option").forEach(el => {
el.addEventListener("click", function(e) {
el.addEventListener("click", function (e) {
e.stopPropagation();
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
this.classList.add("selected");
@@ -387,7 +388,7 @@ export async function loadFolderTree(selectedFolder) {
// Event binding for toggling folders.
const rootToggle = container.querySelector("#rootRow .folder-toggle");
if (rootToggle) {
rootToggle.addEventListener("click", function(e) {
rootToggle.addEventListener("click", function (e) {
e.stopPropagation();
const nestedUl = container.querySelector("#rootRow + ul");
if (nestedUl) {
@@ -409,7 +410,7 @@ export async function loadFolderTree(selectedFolder) {
}
container.querySelectorAll(".folder-toggle").forEach(toggle => {
toggle.addEventListener("click", function(e) {
toggle.addEventListener("click", function (e) {
e.stopPropagation();
const siblingUl = this.parentNode.querySelector("ul");
const folderPath = this.getAttribute("data-folder");
@@ -459,12 +460,12 @@ function openRenameFolderModal() {
document.getElementById("renameFolderModal").style.display = "block";
}
document.getElementById("cancelRenameFolder").addEventListener("click", function() {
document.getElementById("cancelRenameFolder").addEventListener("click", function () {
document.getElementById("renameFolderModal").style.display = "none";
document.getElementById("newRenameFolderName").value = "";
});
document.getElementById("submitRenameFolder").addEventListener("click", function(event) {
document.getElementById("submitRenameFolder").addEventListener("click", function (event) {
event.preventDefault();
const selectedFolder = window.currentFolder || "root";
const newNameBasename = document.getElementById("newRenameFolderName").value.trim();
@@ -517,11 +518,11 @@ function openDeleteFolderModal() {
document.getElementById("deleteFolderModal").style.display = "block";
}
document.getElementById("cancelDeleteFolder").addEventListener("click", function() {
document.getElementById("cancelDeleteFolder").addEventListener("click", function () {
document.getElementById("deleteFolderModal").style.display = "none";
});
document.getElementById("confirmDeleteFolder").addEventListener("click", function() {
document.getElementById("confirmDeleteFolder").addEventListener("click", function () {
const selectedFolder = window.currentFolder || "root";
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch("deleteFolder.php", {
@@ -549,16 +550,16 @@ document.getElementById("confirmDeleteFolder").addEventListener("click", functio
});
});
document.getElementById("createFolderBtn").addEventListener("click", function() {
document.getElementById("createFolderBtn").addEventListener("click", function () {
document.getElementById("createFolderModal").style.display = "block";
});
document.getElementById("cancelCreateFolder").addEventListener("click", function() {
document.getElementById("cancelCreateFolder").addEventListener("click", function () {
document.getElementById("createFolderModal").style.display = "none";
document.getElementById("newFolderName").value = "";
});
document.getElementById("submitCreateFolder").addEventListener("click", function() {
document.getElementById("submitCreateFolder").addEventListener("click", function () {
const folderInput = document.getElementById("newFolderName").value.trim();
if (!folderInput) {
showToast("Please enter a folder name.");

View File

@@ -20,6 +20,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/xml/xml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/css/css.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/javascript/javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/resumable.js/1.1.0/resumable.min.js"></script>
<link rel="stylesheet" href="styles.css" />
</head>
@@ -161,15 +162,16 @@
<div class="card-header">Upload Files/Folders</div>
<div class="card-body d-flex flex-column">
<form id="uploadFileForm" method="post" enctype="multipart/form-data" class="d-flex flex-column"
style="height: 100%;">
style="height: 100%;" novalidate>
<div class="form-group flex-grow-1" style="margin-bottom: 1rem;">
<div id="uploadDropArea"
style="border:2px dashed #ccc; padding:20px; cursor:pointer; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center;">
<span>Drop files/folders here or click 'Choose files'</span>
style="border:2px dashed #ccc; padding:20px; cursor:pointer; height:100%; display:flex; flex-direction:column; justify-content:center; align-items:center; position:relative;">
<span>Drop files/folders here or click 'Choose Files'</span>
<br />
<input type="file" id="file" name="file[]" class="form-control-file" multiple required
webkitdirectory directory mozdirectory style="opacity:0; position:absolute; z-index:-1;" />
<button type="button" onclick="document.getElementById('file').click();">Choose Folder</button>
<!-- Note: Remove directory attributes so file picker only allows files -->
<input type="file" id="file" name="file[]" class="form-control-file" multiple
style="opacity:0; position:absolute; width:1px; height:1px;" />
<button type="button" id="customChooseBtn">Choose Files</button>
</div>
</div>
<button type="submit" id="uploadBtn" class="btn btn-primary d-block mx-auto">Upload</button>
@@ -181,7 +183,8 @@
<!-- Folder Management Card -->
<div class="col-md-6 col-lg-5 d-flex">
<div id="folderManagementCard" class="card flex-fill" style="max-width: 900px; width: 100%; position: relative;">
<div id="folderManagementCard" class="card flex-fill"
style="max-width: 900px; width: 100%; position: relative;">
<!-- Card header with folder management title and help icon -->
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
<span>Folder Navigation &amp; Management</span>
@@ -319,16 +322,17 @@
</div>
<!-- Change Password-->
<div id="changePasswordModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:400px; margin:auto;">
<span id="closeChangePasswordModal" style="cursor:pointer;">&times;</span>
<h3>Change Password</h3>
<input type="password" id="oldPassword" placeholder="Old Password" style="width:100%; margin: 5px 0;" />
<input type="password" id="newPassword" placeholder="New Password" style="width:100%; margin: 5px 0;" />
<input type="password" id="confirmPassword" placeholder="Confirm New Password" style="width:100%; margin: 5px 0;" />
<button id="saveNewPasswordBtn" class="btn btn-primary" style="width:100%;">Save</button>
<div id="changePasswordModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:400px; margin:auto;">
<span id="closeChangePasswordModal" style="cursor:pointer;">&times;</span>
<h3>Change Password</h3>
<input type="password" id="oldPassword" placeholder="Old Password" style="width:100%; margin: 5px 0;" />
<input type="password" id="newPassword" placeholder="New Password" style="width:100%; margin: 5px 0;" />
<input type="password" id="confirmPassword" placeholder="Confirm New Password"
style="width:100%; margin: 5px 0;" />
<button id="saveNewPasswordBtn" class="btn btn-primary" style="width:100%;">Save</button>
</div>
</div>
</div>
<!-- Add User Modal -->
<div id="addUserModal" class="modal">

63
removeChunks.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// Validate CSRF token from POST
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
if ($receivedToken !== $_SESSION['csrf_token']) {
echo json_encode(["error" => "Invalid CSRF token"]);
http_response_code(403);
exit;
}
// Ensure a folder parameter is provided
if (!isset($_POST['folder'])) {
echo json_encode(["error" => "No folder specified"]);
http_response_code(400);
exit;
}
$folder = $_POST['folder'];
// Validate the folder name to allow only expected characters (adjust the regex as needed)
if (!preg_match('/^resumable_[A-Za-z0-9\-]+$/', $folder)) {
echo json_encode(["error" => "Invalid folder name"]);
http_response_code(400);
exit;
}
$tempDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
// If the folder doesn't exist, simply return success.
if (!is_dir($tempDir)) {
echo json_encode(["success" => true, "message" => "Temporary folder already removed."]);
exit;
}
// Recursively delete directory and its contents
function rrmdir($dir) {
if (is_dir($dir)) {
$objects = scandir($dir);
foreach ($objects as $object) {
if ($object != "." && $object != "..") {
$path = $dir . DIRECTORY_SEPARATOR . $object;
if (is_dir($path) && !is_link($path)) {
rrmdir($path);
} else {
@unlink($path);
}
}
}
@rmdir($dir);
}
}
rrmdir($tempDir);
// check if folder still exists
if (!is_dir($tempDir)) {
echo json_encode(["success" => true, "message" => "Temporary folder removed."]);
} else {
echo json_encode(["error" => "Failed to remove temporary folder."]);
http_response_code(500);
}
?>

View File

@@ -12,6 +12,7 @@ body {
body {
letter-spacing: 0.2px;
overflow-x: hidden;
}
.custom-dash {
@@ -22,17 +23,13 @@ body {
}
/* CONTAINER */
.container {
margin-top: 20px;
}
.container,
.container-fluid {
padding-left: 5px !important;
padding-right: 5px !important;
margin-top: 20px;
padding-right: 4px !important;
padding-left: 4px !important;
}
/* Increase left/right padding for larger screens */
@media (min-width: 768px) {
.container-fluid {
padding-left: 50px !important;
@@ -54,6 +51,8 @@ body {
/************************************************************/
/* FLEXBOX HEADER: LOGO, TITLE, BUTTONS FIXED */
/************************************************************/
#uploadCard,
#folderManagementCard {
min-height: 342px;
@@ -217,7 +216,6 @@ body.dark-mode header {
background-color: rgba(255, 255, 255, 0.2);
}
/* Folder Help Tooltip - Light Mode */
.folder-help-tooltip {
background-color: #fff;
color: #333;
@@ -227,7 +225,6 @@ body.dark-mode header {
box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.2);
}
/* Folder Help Tooltip - Dark Mode */
body.dark-mode .folder-help-tooltip {
background-color: #333 !important;
color: #eee !important;
@@ -321,7 +318,7 @@ body.dark-mode .material-icons.gallery-icon {
border: none;
color: red;
cursor: pointer;
margin-right: 8px;
margin-right: 0px;
padding: 0;
border-radius: 50%;
transition: background-color 0.3s;
@@ -535,17 +532,6 @@ body.dark-mode .modal .modal-content {
border-color: #444;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 10px;
}
body.dark-mode .editor-header {
background-color: #2c2c2c;
}
.editor-close-btn {
position: absolute;
top: 10px;
@@ -588,12 +574,12 @@ body.dark-mode .editor-close-btn:hover {
/* Editor Modal */
.editor-modal {
position: fixed;
top: 5%;
top: 2%;
left: 5%;
width: 90vw;
height: 90vh;
background-color: #fff;
padding: 20px;
padding: 10px 20px 20px 20px;
border: 1px solid #ccc;
border-radius: 4px !important;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2) !important;
@@ -630,15 +616,25 @@ body.dark-mode .editor-modal {
}
}
.editor-title {
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
font-size: 1.5rem;
max-width: 95%;
display: block;
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 33px;
padding: 0 10px;
margin-bottom: 7px;
}
.editor-title {
margin: 0;
line-height: 33px;
}
body.dark-mode .editor-header {
background-color: #2c2c2c;
}
@media (max-width: 600px) {
.editor-title {
font-size: 1.2rem;
@@ -648,6 +644,7 @@ body.dark-mode .editor-modal {
.editor-controls {
text-align: right;
margin-right: 30px;
margin-bottom: 5px;
}
@@ -704,6 +701,24 @@ body.dark-mode .editor-modal {
/* ===========================================================
UPLOAD PROGRESS STYLES
=========================================================== */
.pause-resume-btn {
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
outline: none;
margin-right: 5px;
}
.material-icons.pauseResumeBtn {
color: black !important;
}
body.dark-mode .material-icons.pauseResumeBtn {
color: white !important;
}
#uploadProgressContainer ul {
list-style: none;
padding: 0;
@@ -913,6 +928,7 @@ body.dark-mode #fileList table tr {
word-break: break-word !important;
text-align: left !important;
line-height: 1.2 !important;
vertical-align: middle !important;
padding: 8px 10px !important;
max-width: 250px !important;
min-width: 120px !important;
@@ -1143,9 +1159,15 @@ body.dark-mode .folder-option:hover {
}
#fileListContainer {
padding: 10px;
margin-top: 20px;
margin-bottom: 20px;
max-width: 100%;
padding: 10px 5px;
margin: 20px auto;
}
@media (max-width: 750px) {
#fileListContainer {
width: 99%;
}
}
body.dark-mode #fileListContainer {
@@ -1153,9 +1175,11 @@ body.dark-mode #fileListContainer {
color: #e0e0e0;
border: 1px solid #444;
border-radius: 8px;
padding: 10px;
padding-top: 10px !important;
padding-bottom: 10px !important;
padding-left: 5px !important;
padding-right: 5px !important;
margin-top: 20px;
}
#fileListContainer>h2,
@@ -1174,7 +1198,7 @@ body.dark-mode #fileListContainer {
}
.col-12.col-md-4.text-left {
margin-left: -15px;
margin-left: -17px;
}
@media (max-width: 600px) {
@@ -1245,7 +1269,6 @@ body.dark-mode #fileListContainer {
/* ===========================================================
FOLDER TREE STYLES
=========================================================== */
/* Make breadcrumb links look clickable */
.breadcrumb-link {
cursor: pointer;
color: #007bff;
@@ -1312,12 +1335,13 @@ body.dark-mode #fileListContainer {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
text-align: center;
min-height: 30px;
margin: 0 auto 10px;
padding: 10px;
width: 90% !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: 25px;
padding: 5px;
margin-bottom: 10px;
max-width: 90%;
}
.image-preview-modal-content {
@@ -1599,7 +1623,7 @@ body.dark-mode .btn-secondary {
#toggleViewBtn {
margin-bottom: 20px;
margin-left: 15px;
margin-left: 14px;
padding: 10px 20px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
@@ -1611,9 +1635,15 @@ body.dark-mode .btn-secondary {
transition: background 0.3s ease, box-shadow 0.3s ease;
}
@media (max-width: 768px) {
#toggleViewBtn {
margin-left: 0 !important;
}
}
@media (max-width: 600px) {
#toggleViewBtn {
margin-left: auto !important;
margin-left: 0 !important;
margin-right: auto !important;
display: block !important;
}

View File

@@ -47,7 +47,6 @@ function showConfirm(message, onConfirm) {
* This function should be called from main.js after authentication.
*/
export function setupTrashRestoreDelete() {
console.log("Setting up trash restore/delete listeners.");
// --- Attach listener to the restore button (created in auth.js) to open the modal.
const restoreBtn = document.getElementById("restoreFilesBtn");
@@ -57,7 +56,6 @@ export function setupTrashRestoreDelete() {
loadTrashItems();
});
} else {
console.warn("restoreFilesBtn not found. It may not be available for the current user.");
setTimeout(() => {
const retryBtn = document.getElementById("restoreFilesBtn");
if (retryBtn) {

470
upload.js
View File

@@ -2,11 +2,15 @@ import { loadFileList, displayFilePreview, initFileActions } from './fileManager
import { showToast, escapeHTML } from './domUtils.js';
import { loadFolderTree } from './folderManager.js';
// Helper: Recursively traverse a dropped folder.
/* -----------------------------------------------------
Helpers for DragandDrop Folder Uploads (Original Code)
----------------------------------------------------- */
// Recursively traverse a dropped folder.
function traverseFileTreePromise(item, path = "") {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
if (item.isFile) {
item.file(file => {
// Store relative path for folder uploads.
Object.defineProperty(file, 'customRelativePath', {
value: path + file.name,
writable: true,
@@ -29,7 +33,7 @@ function traverseFileTreePromise(item, path = "") {
});
}
// Helper: Given DataTransfer items, recursively retrieve files.
// Recursively retrieve files from DataTransfer items.
function getFilesFromDataTransferItems(items) {
const promises = [];
for (let i = 0; i < items.length; i++) {
@@ -41,25 +45,27 @@ function getFilesFromDataTransferItems(items) {
return Promise.all(promises).then(results => results.flat());
}
// Helper: Set default drop area content.
/* -----------------------------------------------------
UI Helpers (Mostly unchanged from your original code)
----------------------------------------------------- */
function setDropAreaDefault() {
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) {
dropArea.innerHTML = `
<div id="uploadInstruction" class="upload-instruction">
Drop files/folders here or click 'Choose files'
</div>
<div id="uploadFileRow" class="upload-file-row">
<button id="customChooseBtn" type="button">
Choose files
</button>
</div>
<div id="fileInfoWrapper" class="file-info-wrapper">
<div id="fileInfoContainer" class="file-info-container">
<span id="fileInfoDefault">No files selected</span>
</div>
</div>
`;
<div id="uploadInstruction" class="upload-instruction">
Drop files/folders here or click 'Choose files'
</div>
<div id="uploadFileRow" class="upload-file-row">
<button id="customChooseBtn" type="button">Choose files</button>
</div>
<div id="fileInfoWrapper" class="file-info-wrapper">
<div id="fileInfoContainer" class="file-info-container">
<span id="fileInfoDefault">No files selected</span>
</div>
</div>
<!-- File input for file picker (files only) -->
<input type="file" id="file" name="file[]" class="form-control-file" multiple style="opacity:0; position:absolute; width:1px; height:1px;" />
`;
}
}
@@ -82,7 +88,6 @@ function adjustFolderHelpExpansionClosed() {
}
}
// Helper: Update file info container count/preview.
function updateFileInfoCount() {
const fileInfoContainer = document.getElementById("fileInfoContainer");
if (fileInfoContainer && window.selectedFiles) {
@@ -90,64 +95,180 @@ function updateFileInfoCount() {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
} else if (window.selectedFiles.length === 1) {
fileInfoContainer.innerHTML = `
<div id="filePreviewContainer" class="file-preview-container" style="display:inline-block;"></div>
<span id="fileNameDisplay" class="file-name-display">${escapeHTML(window.selectedFiles[0].name)}</span>
<div id="filePreviewContainer" class="file-preview-container" style="display:inline-block;">
<span class="material-icons file-icon">insert_drive_file</span>
</div>
<span id="fileNameDisplay" class="file-name-display">${escapeHTML(window.selectedFiles[0].name || window.selectedFiles[0].fileName || "Unnamed File")}</span>
`;
} else {
fileInfoContainer.innerHTML = `
<div id="filePreviewContainer" class="file-preview-container" style="display:inline-block;"></div>
<div id="filePreviewContainer" class="file-preview-container" style="display:inline-block;">
<span class="material-icons file-icon">insert_drive_file</span>
</div>
<span id="fileCountDisplay" class="file-name-display">${window.selectedFiles.length} files selected</span>
`;
}
const previewContainer = document.getElementById("filePreviewContainer");
if (previewContainer && window.selectedFiles.length > 0) {
previewContainer.innerHTML = "";
displayFilePreview(window.selectedFiles[0], previewContainer);
// For image files, try to show a preview (if available from the file object).
displayFilePreview(window.selectedFiles[0].file || window.selectedFiles[0], previewContainer);
}
}
}
// Helper: Create a file entry element with a remove button.
// Helper function to repeatedly call removeChunks.php
function removeChunkFolderRepeatedly(identifier, csrfToken, maxAttempts = 3, interval = 1000) {
let attempt = 0;
const removalInterval = setInterval(() => {
attempt++;
const params = new URLSearchParams();
// Prefix with "resumable_" to match your PHP regex.
params.append('folder', 'resumable_' + identifier);
params.append('csrf_token', csrfToken);
fetch('removeChunks.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
})
.then(response => response.json())
.then(data => {
console.log(`Chunk folder removal attempt ${attempt}:`, data);
})
.catch(err => {
console.error(`Error on removal attempt ${attempt}:`, err);
});
if (attempt >= maxAttempts) {
clearInterval(removalInterval);
}
}, interval);
}
/* -----------------------------------------------------
File Entry Creation (with Pause/Resume and Restart)
----------------------------------------------------- */
// Create a file entry element with a remove button and a pause/resume button.
function createFileEntry(file) {
const li = document.createElement("li");
li.classList.add("upload-progress-item");
li.style.display = "flex";
li.dataset.uploadIndex = file.uploadIndex;
// Remove button (always added)
const removeBtn = document.createElement("button");
removeBtn.classList.add("remove-file-btn");
removeBtn.textContent = "×";
removeBtn.addEventListener("click", function (e) {
e.stopPropagation();
const uploadIndex = file.uploadIndex;
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
li.remove();
updateFileInfoCount();
});
// In your remove button event listener, replace the fetch call with:
removeBtn.addEventListener("click", function (e) {
e.stopPropagation();
const uploadIndex = file.uploadIndex;
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
// Cancel the file upload if possible.
if (typeof file.cancel === "function") {
file.cancel();
console.log("Canceled file upload:", file.fileName);
}
// Remove file from the resumable queue.
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
resumableInstance.removeFile(file);
}
// Call our helper repeatedly to remove the chunk folder.
if (file.uniqueIdentifier) {
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
}
li.remove();
updateFileInfoCount();
});
li.removeBtn = removeBtn;
li.appendChild(removeBtn);
// Add pause/resume/restart button if the file supports pause/resume.
// Conditionally add the pause/resume button only if file.pause is available
// Pause/Resume button (for resumable filepicker uploads)
if (typeof file.pause === "function") {
const pauseResumeBtn = document.createElement("button");
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
pauseResumeBtn.classList.add("pause-resume-btn");
// Start with pause icon and disable button until upload starts
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
pauseResumeBtn.disabled = true;
pauseResumeBtn.addEventListener("click", function (e) {
e.stopPropagation();
if (file.isError) {
// If the file previously failed, try restarting upload.
if (typeof file.retry === "function") {
file.retry();
file.isError = false;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
}
} else if (!file.paused) {
// Pause the upload (if possible)
if (typeof file.pause === "function") {
file.pause();
file.paused = true;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
} else {
}
} else if (file.paused) {
// Resume sequence: first call to resume (or upload() fallback)
if (typeof file.resume === "function") {
file.resume();
} else {
resumableInstance.upload();
}
// After a short delay, pause again then resume
setTimeout(() => {
if (typeof file.pause === "function") {
file.pause();
} else {
resumableInstance.upload();
}
setTimeout(() => {
if (typeof file.resume === "function") {
file.resume();
} else {
resumableInstance.upload();
}
}, 100);
}, 100);
file.paused = false;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
} else {
console.error("Pause/resume function not available for file", file);
}
});
li.appendChild(pauseResumeBtn);
}
// Preview element
const preview = document.createElement("div");
preview.className = "file-preview";
displayFilePreview(file, preview);
li.appendChild(preview);
// File name display
const nameDiv = document.createElement("div");
nameDiv.classList.add("upload-file-name");
nameDiv.textContent = file.name;
nameDiv.textContent = file.name || file.fileName || "Unnamed File";
li.appendChild(nameDiv);
// Progress bar container
const progDiv = document.createElement("div");
progDiv.classList.add("progress", "upload-progress-div");
progDiv.style.flex = "0 0 250px";
progDiv.style.marginLeft = "5px";
const progBar = document.createElement("div");
progBar.classList.add("progress-bar");
progBar.style.width = "0%";
progBar.innerText = "0%";
progDiv.appendChild(progBar);
li.appendChild(removeBtn);
li.appendChild(preview);
li.appendChild(nameDiv);
li.appendChild(progDiv);
li.progressBar = progBar;
@@ -155,7 +276,11 @@ function createFileEntry(file) {
return li;
}
// Process selected files: Build preview/progress list and store files for later submission.
/* -----------------------------------------------------
Processing Files
- For draganddrop, use original processing (supports folders).
- For file picker, if using Resumable, those files use resumable.
----------------------------------------------------- */
function processFiles(filesInput) {
const fileInfoContainer = document.getElementById("fileInfoContainer");
const files = Array.from(filesInput);
@@ -164,12 +289,16 @@ function processFiles(filesInput) {
if (files.length > 0) {
if (files.length === 1) {
fileInfoContainer.innerHTML = `
<div id="filePreviewContainer" class="file-preview-container" style="display:inline-block;"></div>
<span id="fileNameDisplay" class="file-name-display">${escapeHTML(files[0].name)}</span>
<div id="filePreviewContainer" class="file-preview-container" style="display:inline-block;">
<span class="material-icons file-icon">insert_drive_file</span>
</div>
<span id="fileNameDisplay" class="file-name-display">${escapeHTML(files[0].name || files[0].fileName || "Unnamed File")}</span>
`;
} else {
fileInfoContainer.innerHTML = `
<div id="filePreviewContainer" class="file-preview-container" style="display:inline-block;"></div>
<div id="filePreviewContainer" class="file-preview-container" style="display:inline-block;">
<span class="material-icons file-icon">insert_drive_file</span>
</div>
<span id="fileCountDisplay" class="file-name-display">${files.length} files selected</span>
`;
}
@@ -195,12 +324,14 @@ function processFiles(filesInput) {
const list = document.createElement("ul");
list.classList.add("upload-progress-list");
// Check for relative paths (for folder uploads).
const hasRelativePaths = files.some(file => {
const rel = file.webkitRelativePath || file.customRelativePath || "";
return rel.trim() !== "";
});
if (hasRelativePaths) {
// Group files by folder.
const fileGroups = {};
files.forEach(file => {
let folderName = "Root";
@@ -218,11 +349,13 @@ function processFiles(filesInput) {
});
Object.keys(fileGroups).forEach(folderName => {
const folderLi = document.createElement("li");
folderLi.classList.add("upload-folder-group");
folderLi.innerHTML = `<i class="material-icons folder-icon" style="vertical-align:middle; margin-right:8px;">folder</i> ${folderName}:`;
list.appendChild(folderLi);
// Only show folder grouping if folderName is not "Root"
if (folderName !== "Root") {
const folderLi = document.createElement("li");
folderLi.classList.add("upload-folder-group");
folderLi.innerHTML = `<i class="material-icons folder-icon" style="vertical-align:middle; margin-right:8px;">folder</i> ${folderName}:`;
list.appendChild(folderLi);
}
const nestedUl = document.createElement("ul");
nestedUl.classList.add("upload-folder-group-list");
fileGroups[folderName]
@@ -234,6 +367,7 @@ function processFiles(filesInput) {
list.appendChild(nestedUl);
});
} else {
// No relative paths list files directly.
files.forEach((file, index) => {
const li = createFileEntry(file);
li.style.display = (index < maxDisplay) ? "flex" : "none";
@@ -263,7 +397,159 @@ function processFiles(filesInput) {
updateFileInfoCount();
}
// Function to handle file uploads; triggered when the user clicks the "Upload" button.
/* -----------------------------------------------------
Resumable.js Integration for File Picker Uploads
(Only files chosen via file input use Resumable; folder uploads use original code.)
----------------------------------------------------- */
const useResumable = true; // Enable resumable for file picker uploads
let resumableInstance;
function initResumableUpload() {
resumableInstance = new Resumable({
target: "upload.php",
query: { folder: window.currentFolder || "root", upload_token: window.csrfToken },
chunkSize: 3 * 1024 * 1024, // 3 MB chunks
simultaneousUploads: 3,
testChunks: false,
throttleProgressCallbacks: 1,
headers: { "X-CSRF-Token": window.csrfToken }
});
const fileInput = document.getElementById("file");
if (fileInput) {
// Assign Resumable to file input for file picker uploads.
resumableInstance.assignBrowse(fileInput);
fileInput.addEventListener("change", function () {
for (let i = 0; i < fileInput.files.length; i++) {
resumableInstance.addFile(fileInput.files[i]);
}
});
}
resumableInstance.on("fileAdded", function (file) {
// Initialize custom paused flag
file.paused = false;
file.uploadIndex = file.uniqueIdentifier;
if (!window.selectedFiles) {
window.selectedFiles = [];
}
window.selectedFiles.push(file);
const progressContainer = document.getElementById("uploadProgressContainer");
// Check if a wrapper already exists; if not, create one with a UL inside.
let listWrapper = progressContainer.querySelector(".upload-progress-wrapper");
let list;
if (!listWrapper) {
listWrapper = document.createElement("div");
listWrapper.classList.add("upload-progress-wrapper");
listWrapper.style.maxHeight = "300px";
listWrapper.style.overflowY = "auto";
list = document.createElement("ul");
list.classList.add("upload-progress-list");
listWrapper.appendChild(list);
progressContainer.appendChild(listWrapper);
} else {
list = listWrapper.querySelector("ul.upload-progress-list");
}
const li = createFileEntry(file);
li.dataset.uploadIndex = file.uniqueIdentifier;
list.appendChild(li);
updateFileInfoCount();
});
resumableInstance.on("fileProgress", function (file) {
const percent = Math.floor(file.progress() * 100);
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
if (li && li.progressBar) {
li.progressBar.style.width = percent + "%";
// Calculate elapsed time since file entry was created.
const elapsed = (Date.now() - li.startTime) / 1000;
let speed = "";
if (elapsed > 0) {
// Calculate total bytes uploaded so far using file.progress() * file.size
const bytesUploaded = file.progress() * file.size;
const spd = bytesUploaded / elapsed;
if (spd < 1024) {
speed = spd.toFixed(0) + " B/s";
} else if (spd < 1048576) {
speed = (spd / 1024).toFixed(1) + " KB/s";
} else {
speed = (spd / 1048576).toFixed(1) + " MB/s";
}
}
li.progressBar.innerText = percent + "% (" + speed + ")";
// Enable the pause/resume button once progress starts
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) {
pauseResumeBtn.disabled = false;
}
}
});
resumableInstance.on("fileSuccess", function (file, message) {
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
if (li && li.progressBar) {
li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done";
// Hide the pause/resume button when upload is complete.
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) {
pauseResumeBtn.style.display = "none";
}
const removeBtn = li.querySelector(".remove-file-btn");
if (removeBtn) {
removeBtn.style.display = "none";
}
}
loadFileList(window.currentFolder);
});
resumableInstance.on("fileError", function (file, message) {
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
if (li && li.progressBar) {
li.progressBar.innerText = "Error";
}
// Mark file as errored so that the pause/resume button acts as a restart button.
file.isError = true;
// Change the pause/resume button to show a restart icon.
const pauseResumeBtn = li ? li.querySelector(".pause-resume-btn") : null;
if (pauseResumeBtn) {
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">replay</span>';
pauseResumeBtn.disabled = false;
}
showToast("Error uploading file: " + file.fileName);
});
resumableInstance.on("complete", function () {
// Check if any file in the current selection is marked with an error.
const hasError = window.selectedFiles.some(f => f.isError);
if (!hasError) {
// All files succeeded; clear the file list after 5 seconds.
setTimeout(() => {
if (fileInput) fileInput.value = "";
const progressContainer = document.getElementById("uploadProgressContainer");
progressContainer.innerHTML = "";
window.selectedFiles = [];
adjustFolderHelpExpansionClosed();
window.addEventListener("resize", adjustFolderHelpExpansionClosed);
const fileInfoContainer = document.getElementById("fileInfoContainer");
if (fileInfoContainer) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
}
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault();
}, 5000);
} else {
showToast("Some files failed to upload. Please check the list.");
}
});
}
/* -----------------------------------------------------
XHR-based submitFiles for DragandDrop (Folder) Uploads
----------------------------------------------------- */
function submitFiles(allFiles) {
const folderToUse = window.currentFolder || "root";
const progressContainer = document.getElementById("uploadProgressContainer");
@@ -323,9 +609,7 @@ function submitFiles(allFiles) {
if (li) {
li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done";
if (li.removeBtn) {
li.removeBtn.style.display = "none";
}
if (li.removeBtn) li.removeBtn.style.display = "none";
}
uploadResults[file.uploadIndex] = true;
} else {
@@ -367,7 +651,6 @@ function submitFiles(allFiles) {
});
xhr.open("POST", "upload.php", true);
// Set the CSRF token header to match the folderManager approach.
xhr.setRequestHeader("X-CSRF-Token", window.csrfToken);
xhr.send(formData);
});
@@ -377,35 +660,40 @@ function submitFiles(allFiles) {
.then(serverFiles => {
initFileActions();
serverFiles = (serverFiles || []).map(item => item.name.trim().toLowerCase());
let allSucceeded = true;
allFiles.forEach(file => {
if ((file.webkitRelativePath || file.customRelativePath || "").trim() !== "") {
return;
}
const clientFileName = file.name.trim().toLowerCase();
if (!uploadResults[file.uploadIndex] || !serverFiles.includes(clientFileName)) {
const li = progressElements[file.uploadIndex];
if (li) {
li.progressBar.innerText = "Error";
// For files without a relative path
if ((file.webkitRelativePath || file.customRelativePath || "").trim() === "") {
const clientFileName = file.name.trim().toLowerCase();
if (!uploadResults[file.uploadIndex] || !serverFiles.includes(clientFileName)) {
const li = progressElements[file.uploadIndex];
if (li) {
li.progressBar.innerText = "Error";
}
allSucceeded = false;
}
allSucceeded = false;
}
});
setTimeout(() => {
if (fileInput) fileInput.value = "";
const removeBtns = progressContainer.querySelectorAll("button.remove-file-btn");
removeBtns.forEach(btn => btn.style.display = "none");
progressContainer.innerHTML = "";
window.selectedFiles = [];
adjustFolderHelpExpansionClosed();
window.addEventListener("resize", adjustFolderHelpExpansionClosed);
const fileInfoContainer = document.getElementById("fileInfoContainer");
if (fileInfoContainer) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
}
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault();
}, 5000);
if (!allSucceeded) {
if (allSucceeded) {
// All files succeeded—clear the list after 5 seconds.
setTimeout(() => {
if (fileInput) fileInput.value = "";
const removeBtns = progressContainer.querySelectorAll("button.remove-file-btn");
removeBtns.forEach(btn => btn.style.display = "none");
progressContainer.innerHTML = "";
window.selectedFiles = [];
adjustFolderHelpExpansionClosed();
window.addEventListener("resize", adjustFolderHelpExpansionClosed);
const fileInfoContainer = document.getElementById("fileInfoContainer");
if (fileInfoContainer) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
}
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault();
}, 5000);
} else {
// Some files failed—keep the list visible and show a toast.
showToast("Some files failed to upload. Please check the list.");
}
})
@@ -419,12 +707,15 @@ function submitFiles(allFiles) {
}
}
// Main initUpload: sets up file input, drop area, and form submission.
/* -----------------------------------------------------
Main initUpload: Sets up file input, drop area, and form submission.
----------------------------------------------------- */
function initUpload() {
const fileInput = document.getElementById("file");
const dropArea = document.getElementById("uploadDropArea");
const uploadForm = document.getElementById("uploadFileForm");
// For file picker, remove directory attributes so only files can be chosen.
if (fileInput) {
fileInput.removeAttribute("webkitdirectory");
fileInput.removeAttribute("mozdirectory");
@@ -434,6 +725,7 @@ function initUpload() {
setDropAreaDefault();
// Draganddrop events (for folder uploads) use original processing.
if (dropArea) {
dropArea.classList.add("upload-drop-area");
dropArea.addEventListener("dragover", function (e) {
@@ -458,6 +750,7 @@ function initUpload() {
processFiles(dt.files);
}
});
// Clicking drop area triggers file input.
dropArea.addEventListener("click", function () {
if (fileInput) fileInput.click();
});
@@ -465,7 +758,14 @@ function initUpload() {
if (fileInput) {
fileInput.addEventListener("change", function () {
processFiles(fileInput.files);
if (useResumable) {
// For file picker, if resumable is enabled, let it handle the files.
for (let i = 0; i < fileInput.files.length; i++) {
resumableInstance.addFile(fileInput.files[i]);
}
} else {
processFiles(fileInput.files);
}
});
}
@@ -477,9 +777,21 @@ function initUpload() {
showToast("No files selected.");
return;
}
submitFiles(files);
// If files come from file picker (no relative path), use Resumable.
if (useResumable && (!files[0].customRelativePath || files[0].customRelativePath === "")) {
// Ensure current folder is updated.
resumableInstance.opts.query.folder = window.currentFolder || "root";
resumableInstance.upload();
showToast("Resumable upload started...");
} else {
submitFiles(files);
}
});
}
if (useResumable) {
initResumableUpload();
}
}
export { initUpload };

View File

@@ -12,122 +12,225 @@ if ($receivedToken !== $_SESSION['csrf_token']) {
exit;
}
// Ensure user is authenticated
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
http_response_code(401);
exit;
}
// Validate folder name input.
$folder = isset($_POST['folder']) ? trim($_POST['folder']) : 'root';
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
echo json_encode(["error" => "Invalid folder name"]);
exit;
}
// Determine the base upload directory.
$baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
if (!is_dir($baseUploadDir)) {
mkdir($baseUploadDir, 0775, true);
// Check if this is a chunked upload (Resumable.js sends "resumableChunkNumber").
if (isset($_POST['resumableChunkNumber'])) {
// ------------- Chunked Upload Handling -------------
$chunkNumber = intval($_POST['resumableChunkNumber']); // current chunk (1-indexed)
$totalChunks = intval($_POST['resumableTotalChunks']);
$chunkSize = intval($_POST['resumableChunkSize']);
$totalSize = intval($_POST['resumableTotalSize']);
$resumableIdentifier = $_POST['resumableIdentifier']; // unique file identifier
$resumableFilename = $_POST['resumableFilename'];
$folder = isset($_POST['folder']) ? trim($_POST['folder']) : 'root';
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
echo json_encode(["error" => "Invalid folder name"]);
exit;
}
} else {
if (!is_dir($baseUploadDir)) {
mkdir($baseUploadDir, 0775, true);
// Determine the base upload directory.
$baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
if (!is_dir($baseUploadDir)) {
mkdir($baseUploadDir, 0775, true);
}
} else {
if (!is_dir($baseUploadDir)) {
mkdir($baseUploadDir, 0775, true);
}
}
}
// Prepare a collection to hold metadata for each folder.
$metadataCollection = []; // key: folder path, value: metadata array
$metadataChanged = []; // key: folder path, value: boolean
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
foreach ($_FILES["file"]["name"] as $index => $fileName) {
$safeFileName = basename($fileName);
if (!preg_match($safeFileNamePattern, $safeFileName)) {
echo json_encode(["error" => "Invalid file name: " . $fileName]);
// Use a temporary directory for the chunks.
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir)) {
mkdir($tempDir, 0775, true);
}
// Save the current chunk.
$chunkFile = $tempDir . $chunkNumber; // store chunk using its number as filename
if (!move_uploaded_file($_FILES["file"]["tmp_name"], $chunkFile)) {
echo json_encode(["error" => "Failed to move uploaded chunk"]);
exit;
}
// --- Minimal Folder/Subfolder Logic ---
$relativePath = '';
if (isset($_POST['relativePath'])) {
if (is_array($_POST['relativePath'])) {
$relativePath = $_POST['relativePath'][$index] ?? '';
} else {
$relativePath = $_POST['relativePath'];
// Check if all chunks have been uploaded.
$uploadedChunks = glob($tempDir . "*");
if (count($uploadedChunks) >= $totalChunks) {
// All chunks uploaded. Merge chunks.
$targetPath = $baseUploadDir . $resumableFilename;
if (!$out = fopen($targetPath, "wb")) {
echo json_encode(["error" => "Failed to open target file for writing"]);
exit;
}
}
// Determine the complete folder path for upload and for metadata.
$folderPath = $folder; // Base folder as provided ("root" or a subfolder)
$uploadDir = $baseUploadDir; // Start with the base upload directory
if (!empty($relativePath)) {
$subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') {
// If base folder is 'root', then folderPath is just the subDir
// Otherwise, append the subdirectory to the base folder
$folderPath = ($folder === 'root') ? $subDir : $folder . "/" . $subDir;
// Update the upload directory accordingly.
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderPath) . DIRECTORY_SEPARATOR;
// Concatenate each chunk in order.
for ($i = 1; $i <= $totalChunks; $i++) {
$chunkPath = $tempDir . $i;
if (!$in = fopen($chunkPath, "rb")) {
fclose($out);
echo json_encode(["error" => "Failed to open chunk $i"]);
exit;
}
while ($buff = fread($in, 4096)) {
fwrite($out, $buff);
}
fclose($in);
}
// Ensure the file name is taken from the relative path.
$safeFileName = basename($relativePath);
}
// --- End Minimal Folder/Subfolder Logic ---
// Make sure the final upload directory exists.
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
}
$targetPath = $uploadDir . $safeFileName;
if (move_uploaded_file($_FILES["file"]["tmp_name"][$index], $targetPath)) {
// Generate a unique metadata file name based on the folder path.
// Replace slashes, backslashes, and spaces with dashes.
fclose($out);
// --- Metadata Update for Chunked Upload ---
// For chunked uploads, assume no relativePath; so folderPath is simply $folder.
$folderPath = $folder;
$metadataKey = ($folderPath === '' || $folderPath === 'root') ? "root" : $folderPath;
// Generate a metadata file name based on the folder path.
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
// Load metadata for this folder if not already loaded.
if (!isset($metadataCollection[$metadataKey])) {
if (file_exists($metadataFile)) {
$metadataCollection[$metadataKey] = json_decode(file_get_contents($metadataFile), true);
} else {
$metadataCollection[$metadataKey] = [];
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
// Load existing metadata, if any.
if (file_exists($metadataFile)) {
$metadataCollection = json_decode(file_get_contents($metadataFile), true);
if (!is_array($metadataCollection)) {
$metadataCollection = [];
}
$metadataChanged[$metadataKey] = false;
} else {
$metadataCollection = [];
}
// Add metadata for this file if not already present.
if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
$metadataCollection[$metadataKey][$safeFileName] = [
if (!isset($metadataCollection[$resumableFilename])) {
$metadataCollection[$resumableFilename] = [
"uploaded" => $uploadedDate,
"uploader" => $uploader
];
$metadataChanged[$metadataKey] = true;
file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT));
}
// --- End Metadata Update ---
// Cleanup: remove the temporary directory and its chunks.
array_map('unlink', glob("$tempDir*"));
rmdir($tempDir);
echo json_encode(["success" => "File uploaded successfully"]);
exit;
} else {
echo json_encode(["error" => "Error uploading file"]);
// Chunk successfully uploaded, but more chunks remain.
echo json_encode(["status" => "chunk uploaded"]);
exit;
}
}
// After processing all files, write out metadata files for folders that changed.
foreach ($metadataCollection as $folderKey => $data) {
if ($metadataChanged[$folderKey]) {
$metadataFileName = str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
file_put_contents($metadataFile, json_encode($data, JSON_PRETTY_PRINT));
} else {
// ------------- Full Upload (Non-chunked) -------------
// Validate folder name input.
$folder = isset($_POST['folder']) ? trim($_POST['folder']) : 'root';
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
echo json_encode(["error" => "Invalid folder name"]);
exit;
}
// Determine the base upload directory.
$baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
if (!is_dir($baseUploadDir)) {
mkdir($baseUploadDir, 0775, true);
}
} else {
if (!is_dir($baseUploadDir)) {
mkdir($baseUploadDir, 0775, true);
}
}
// Prepare a collection to hold metadata for each folder.
$metadataCollection = []; // key: folder path, value: metadata array
$metadataChanged = []; // key: folder path, value: boolean
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
foreach ($_FILES["file"]["name"] as $index => $fileName) {
$safeFileName = basename($fileName);
if (!preg_match($safeFileNamePattern, $safeFileName)) {
echo json_encode(["error" => "Invalid file name: " . $fileName]);
exit;
}
// --- Minimal Folder/Subfolder Logic ---
$relativePath = '';
if (isset($_POST['relativePath'])) {
if (is_array($_POST['relativePath'])) {
$relativePath = $_POST['relativePath'][$index] ?? '';
} else {
$relativePath = $_POST['relativePath'];
}
}
// Determine the complete folder path for upload and for metadata.
$folderPath = $folder; // Base folder as provided ("root" or a subfolder)
$uploadDir = $baseUploadDir; // Start with the base upload directory
if (!empty($relativePath)) {
$subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') {
$folderPath = ($folder === 'root') ? $subDir : $folder . "/" . $subDir;
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderPath) . DIRECTORY_SEPARATOR;
}
$safeFileName = basename($relativePath);
}
// --- End Minimal Folder/Subfolder Logic ---
// Make sure the final upload directory exists.
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
}
$targetPath = $uploadDir . $safeFileName;
if (move_uploaded_file($_FILES["file"]["tmp_name"][$index], $targetPath)) {
// Generate a unique metadata file name based on the folder path.
$metadataKey = ($folderPath === '' || $folderPath === 'root') ? "root" : $folderPath;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
if (!isset($metadataCollection[$metadataKey])) {
if (file_exists($metadataFile)) {
$metadataCollection[$metadataKey] = json_decode(file_get_contents($metadataFile), true);
} else {
$metadataCollection[$metadataKey] = [];
}
$metadataChanged[$metadataKey] = false;
}
if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
$metadataCollection[$metadataKey][$safeFileName] = [
"uploaded" => $uploadedDate,
"uploader" => $uploader
];
$metadataChanged[$metadataKey] = true;
}
} else {
echo json_encode(["error" => "Error uploading file"]);
exit;
}
}
// After processing all files, write out metadata files for folders that changed.
foreach ($metadataCollection as $folderKey => $data) {
if ($metadataChanged[$folderKey]) {
$metadataFileName = str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
file_put_contents($metadataFile, json_encode($data, JSON_PRETTY_PRINT));
}
}
echo json_encode(["success" => "Files uploaded successfully"]);
}
echo json_encode(["success" => "Files uploaded successfully"]);
?>