From 87d9cf8246b783222f4e6be43b98f9fca876afbc Mon Sep 17 00:00:00 2001 From: Ryan Date: Wed, 19 Mar 2025 02:43:10 -0400 Subject: [PATCH] improvements and new features see changelog --- README.md | 68 ++++-- auth.js | 1 - auth.php | 13 +- config.php | 42 +++- copyFiles.php | 77 ++++--- createFolder.php | 17 ++ createShareLink.php | 59 ++++++ deleteFiles.php | 31 ++- deleteFolder.php | 22 +- domUtils.js | 52 +++-- download.php | 59 ++++++ downloadZip.php | 10 +- fileManager.js | 397 +++++++++++++++++++++++++++++++++-- folderManager.js | 18 +- getFileList.php | 30 ++- getFolderList.php | 60 +++++- index.html | 5 +- main.js | 31 ++- moveFiles.php | 88 ++++++-- renameFile.php | 46 ++-- renameFolder.php | 35 ++- saveFile.php | 36 ++++ share.php | 99 +++++++++ styles.css | 80 ++++++- token.php | 7 +- upload.php | 83 +++++--- uploads/.htaccess | 1 + .htaccess => users/.htaccess | 0 28 files changed, 1247 insertions(+), 220 deletions(-) create mode 100644 createShareLink.php create mode 100644 download.php create mode 100644 share.php create mode 100644 uploads/.htaccess rename .htaccess => users/.htaccess (100%) diff --git a/README.md b/README.md index 959a3bd..1053087 100644 --- a/README.md +++ b/README.md @@ -8,35 +8,63 @@ Multi File Upload Editor is a lightweight, secure web application for uploading, --- -## Features +# Features - **Multiple File/Folder Uploads with Progress:** - - Users can select and upload multiple files & folders at once. Each file upload shows an individual progress bar with percentage and upload speed, and image files display a small thumbnail preview (default icons for other file types). + - 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). - **Built-in File Editing & Renaming:** - - Text-based files (e.g., .txt, .html, .js) can be opened and edited in a modal window without leaving the page. The editor modal is resizable and now uses CodeMirror for syntax highlighting, line numbering, and zoom in/out functionality—allowing users to adjust the text size for a better editing experience. Files can also be renamed via a dedicated “Rename” action without needing to re-upload them. + - Text-based files (e.g., .txt, .html, .js) can be opened and edited in a modal window using CodeMirror for: + - Syntax highlighting + - Line numbering + - Adjustable font sizes + - Files can be renamed directly through the interface. + - The renaming functionality now supports names with parentheses and checks for duplicate names, automatically generating a unique name (e.g., appending “ (1)”) when needed. + - Folder-specific metadata is updated accordingly. - **Built-in File Preview:** - - Users can quickly preview images, videos, and PDFs directly in modal popups without leaving the page. The modal maintains a responsive, centered layout that scales with the content. Different Material icons—image for photos, videocam for videos, and picture_as_pdf for PDFs—provide clear visual cues, and custom CSS (including fine-tuning with negative margins) ensures that icons are perfectly aligned. PDFs are embedded at optimal dimensions for a clear, readable view, while video previews include built-in playback controls. + - Users can quickly preview images, videos, and PDFs directly in modal popups without leaving the page. + - The preview modal supports inline display of images (with proper scaling) and videos with playback controls. + - Navigation (prev/next) within image previews is supported for a seamless browsing experience. +- **Gallery (Grid) View:** + - In addition to the traditional table view, users can toggle to a gallery view that arranges image thumbnails in a grid layout. + - The gallery view offers multiple column options (e.g., 3, 4, or 5 columns) so that users can choose the layout that best fits their screen. + - Action buttons (Download, Edit, Rename, Share) appear beneath each thumbnail for quick access. - **Batch Operations (Delete/Copy/Move/Download):** - - Delete Files: Delete multiple files at once. - - Copy Files: Copy selected files to another folder. - - Move Files: Move selected files to a different folder. - - 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. + - **Delete Files:** Delete multiple files at once. + - **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. - **Folder Management:** - - Supports organizing files into folders and subfolders. Users can create new folders, rename existing folders, or delete folders. A dynamic folder tree in the UI allows navigation through directories and updates in real-time to reflect changes after any create, rename, or delete action. + - 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. - **Sorting & Pagination:** - - The file list can be sorted by name, last modified date, upload date, size, or uploader. For easier browsing, the interface supports pagination with selectable page sizes (10, 20, 50, or 100 items per page) and navigation controls (“Prev”, “Next”, specific page numbers). + - 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. +- **Share Link Functionality:** + - Generate shareable links for files with configurable expiration times (e.g., 30, 60, 120, 180, 240 minutes, and a 1-day option) and optional password protection. + - Share links are stored in a JSON file with details including the folder, file, expiration timestamp, and hashed password. + - The share endpoint (`share.php`) validates tokens, expiration, and password before serving files (or forcing downloads). + - The share URL is configurable via environment variables or auto-detected from the server. - **User Authentication & Management:** - - Secure, session-based authentication protects the editor. An admin user can add or remove users through the interface. Passwords are hashed using PHP’s password_hash() for security, and session checks prevent unauthorized access to backend endpoints. - - **CSRF Protection:** All state-changing endpoints (such as those for folder and file operations) include CSRF token validation to ensure that only legitimate requests from authenticated users are processed. + - Secure, session-based authentication protects the file manager. + - Admin users can add or remove users through the interface. + - Passwords are hashed using PHP’s `password_hash()` for security. + - All state-changing endpoints include CSRF token validation. - **Responsive, Dynamic & Persistent UI:** - - The interface is mobile-friendly and adjusts to different screen sizes (hiding non-critical columns on small devices to avoid clutter). Updates to the file list, folder tree, and upload progress happen asynchronously (via Fetch API and XMLHttpRequest), so the page never needs to fully reload. Users receive immediate feedback through toast notifications and modal dialogs for actions like confirmations and error messages, creating a smooth user experience. Persistent UI elements Items Per Page, Dark/Light Mode, folder tree view & last open folder. -- **Dark Mode/Light Mode** - - Automatically adapts to the operating system’s theme preference by default, with a manual toggle option. - - A theme toggle allows users to switch between Dark Mode and Light Mode for an optimized viewing experience. - - Every element, including the header, buttons, tables, modals, and the file editor, dynamically adapts to the selected theme. - - Dark Mode: Uses a dark gray background with lighter text to reduce eye strain in low-light environments. - - Light Mode: Retains the classic bright interface for high visibility in well-lit conditions. - - CodeMirror editor applies a matching dark theme in Dark Mode for better readability when editing files. + - The interface is mobile-friendly and adapts to various screen sizes by hiding non-critical columns on small devices. + - Asynchronous updates (via Fetch API and XMLHttpRequest) keep the UI responsive without full page reloads. + - Persistent settings (such as items per page, dark/light mode preference, folder tree state, and the last open folder) ensure a smooth and customized user experience. +- **Dark Mode/Light Mode:** + - The application automatically adapts to the operating system’s theme preference by default and offers a manual toggle. + - The dark mode provides a darker background with lighter text and adjusts UI elements (including the CodeMirror editor) for optimal readability in low-light conditions. + - The light mode maintains a bright interface for well-lit environments. +- **Server & Security Enhancements:** + - The Apache configuration (or .htaccess files) is set to disable directory indexing (e.g., using `Options -Indexes` in the uploads directory), preventing unauthorized users from viewing directory contents. + - Direct access to sensitive files (e.g., `users.txt`) is restricted through .htaccess rules. + - A proxy download mechanism has been implemented (via endpoints like `download.php` and `downloadZip.php`) so that every file download request goes through a PHP script. This script validates the session and CSRF token before streaming the file, ensuring that even if a file URL is guessed, only authenticated users can access it. + - Administrators are advised to deploy the app on a secure internal network or use the proxy download mechanism for public deployments to further protect file content. --- diff --git a/auth.js b/auth.js index 62a3ba6..e215663 100644 --- a/auth.js +++ b/auth.js @@ -19,7 +19,6 @@ function initAuth() { // Include CSRF token header with login sendRequest("auth.php", "POST", formData, { "X-CSRF-Token": window.csrfToken }) .then(data => { - console.log("Login response:", data); if (data.success) { console.log("✅ Login successful. Reloading page."); sessionStorage.setItem("welcomeMessage", "Welcome back, " + formData.username + "!"); diff --git a/auth.php b/auth.php index 5d6e4d6..bb771d0 100644 --- a/auth.php +++ b/auth.php @@ -3,16 +3,10 @@ require 'config.php'; header('Content-Type: application/json'); $usersFile = USERS_DIR . USERS_FILE; -/*$headers = array_change_key_case(getallheaders(), CASE_LOWER); -$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : ''; -if ($receivedToken !== $_SESSION['csrf_token']) { - echo json_encode(["error" => "Invalid CSRF token"]); - http_response_code(403); - exit; -}*/ // Function to authenticate user -function authenticate($username, $password) { +function authenticate($username, $password) +{ global $usersFile; if (!file_exists($usersFile)) { @@ -49,6 +43,8 @@ if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $username)) { // Authenticate user $userRole = authenticate($username, $password); if ($userRole !== false) { + // Regenerate session ID to mitigate session fixation attacks + session_regenerate_id(true); $_SESSION["authenticated"] = true; $_SESSION["username"] = $username; $_SESSION["isAdmin"] = ($userRole === "1"); // "1" indicates admin @@ -57,4 +53,3 @@ if ($userRole !== false) { } else { echo json_encode(["error" => "Invalid credentials"]); } -?> \ No newline at end of file diff --git a/config.php b/config.php index f140de0..03447b1 100644 --- a/config.php +++ b/config.php @@ -1,13 +1,48 @@ 7200, + 'path' => '/', + 'domain' => '', // Specify your domain if needed + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax' +]; +session_set_cookie_params($cookieParams); + ini_set('session.gc_maxlifetime', 7200); session_start(); + if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } -// config.php -define('UPLOAD_DIR', '/var/www/uploads/'); + +// Define BASE_URL (this should point to where index.html is, e.g. your uploads directory) define('BASE_URL', 'http://yourwebsite/uploads/'); + +// If BASE_URL is still the default placeholder, use the server's HTTP_HOST. +// Otherwise, use BASE_URL and append share.php. +if (strpos(BASE_URL, 'yourwebsite') !== false) { + $defaultShareUrl = isset($_SERVER['HTTP_HOST']) + ? "http://" . $_SERVER['HTTP_HOST'] . "/share.php" + : "http://localhost/share.php"; +} else { + $defaultShareUrl = rtrim(BASE_URL, '/') . "/share.php"; +} + +define('SHARE_URL', getenv('SHARE_URL') ? getenv('SHARE_URL') : $defaultShareUrl); + +define('UPLOAD_DIR', '/var/www/uploads/'); define('TIMEZONE', 'America/New_York'); define('DATE_TIME_FORMAT', 'm/d/y h:iA'); define('TOTAL_UPLOAD_SIZE', '5G'); @@ -15,5 +50,6 @@ define('USERS_DIR', '/var/www/users/'); define('USERS_FILE', 'users.txt'); define('META_DIR','/var/www/metadata/'); define('META_FILE','file_metadata.json'); + date_default_timezone_set(TIMEZONE); ?> \ No newline at end of file diff --git a/copyFiles.php b/copyFiles.php index 158057a..3a3f455 100644 --- a/copyFiles.php +++ b/copyFiles.php @@ -5,7 +5,6 @@ header('Content-Type: application/json'); // --- CSRF Protection --- $headers = array_change_key_case(getallheaders(), CASE_LOWER); $receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : ''; - if ($receivedToken !== $_SESSION['csrf_token']) { echo json_encode(["error" => "Invalid CSRF token"]); http_response_code(403); @@ -58,54 +57,82 @@ $destDir = ($destinationFolder === 'root') ? $baseDir . DIRECTORY_SEPARATOR : $baseDir . DIRECTORY_SEPARATOR . $destinationFolder . DIRECTORY_SEPARATOR; -// Load metadata. -$metadataFile = META_DIR . META_FILE; -$metadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : []; - -// Ensure destination directory exists. -if (!is_dir($destDir)) { - if (!mkdir($destDir, 0775, true)) { - echo json_encode(["error" => "Could not create destination folder"]); - exit; +// Helper: Generate the metadata file path for a given folder. +function getMetadataFilePath($folder) { + if (strtolower($folder) === 'root' || $folder === '') { + return META_DIR . "root_metadata.json"; } + return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json'; } +// Helper: Generate a unique file name if a file with the same name exists. +function getUniqueFileName($destDir, $fileName) { + $fullPath = $destDir . $fileName; + clearstatcache(true, $fullPath); + if (!file_exists($fullPath)) { + return $fileName; + } + $basename = pathinfo($fileName, PATHINFO_FILENAME); + $extension = pathinfo($fileName, PATHINFO_EXTENSION); + $counter = 1; + do { + $newName = $basename . " (" . $counter . ")" . ($extension ? "." . $extension : ""); + $newFullPath = $destDir . $newName; + clearstatcache(true, $newFullPath); + $counter++; + } while (file_exists($destDir . $newName)); + return $newName; +} + +// Load source and destination metadata. +$srcMetaFile = getMetadataFilePath($sourceFolder); +$destMetaFile = getMetadataFilePath($destinationFolder); + +$srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : []; +$destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : []; + $errors = []; -// Define a safe file name pattern: letters, numbers, underscores, dashes, dots, and spaces. -$safeFileNamePattern = '/^[A-Za-z0-9_\-\. ]+$/'; +// Define a safe file name pattern: letters, numbers, underscores, dashes, dots, parentheses, and spaces. +$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/'; foreach ($files as $fileName) { - $basename = basename(trim($fileName)); - // Validate the file name. + // Save the original name for metadata lookup. + $originalName = basename(trim($fileName)); + $basename = $originalName; if (!preg_match($safeFileNamePattern, $basename)) { $errors[] = "$basename has an invalid name."; continue; } - $srcPath = $sourceDir . $basename; + $srcPath = $sourceDir . $originalName; $destPath = $destDir . $basename; - // Build metadata keys. - $srcKey = ($sourceFolder === 'root') ? $basename : $sourceFolder . "/" . $basename; - $destKey = ($destinationFolder === 'root') ? $basename : $destinationFolder . "/" . $basename; - + clearstatcache(); if (!file_exists($srcPath)) { - $errors[] = "$basename does not exist in source."; + $errors[] = "$originalName does not exist in source."; continue; } + + if (file_exists($destPath)) { + $uniqueName = getUniqueFileName($destDir, $basename); + $basename = $uniqueName; // update the file name for metadata and destination path + $destPath = $destDir . $uniqueName; + } + if (!copy($srcPath, $destPath)) { $errors[] = "Failed to copy $basename"; continue; } - // Update metadata: if source key exists, duplicate it to destination key. - if (isset($metadata[$srcKey])) { - $metadata[$destKey] = $metadata[$srcKey]; + + // Update destination metadata: if there's metadata for the original file in source, add it under the new name. + if (isset($srcMetadata[$originalName])) { + $destMetadata[$basename] = $srcMetadata[$originalName]; } } -if (!file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT))) { - $errors[] = "Failed to update metadata."; +if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) { + $errors[] = "Failed to update destination metadata."; } if (empty($errors)) { diff --git a/createFolder.php b/createFolder.php index 4ce739d..0325793 100644 --- a/createFolder.php +++ b/createFolder.php @@ -50,8 +50,10 @@ if ($parent && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $parent)) { $baseDir = rtrim(UPLOAD_DIR, '/\\'); if ($parent && strtolower($parent) !== "root") { $fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName; + $relativePath = $parent . "/" . $folderName; } else { $fullPath = $baseDir . DIRECTORY_SEPARATOR . $folderName; + $relativePath = $folderName; } // Check if the folder already exists. @@ -62,6 +64,21 @@ if (file_exists($fullPath)) { // Attempt to create the folder. if (mkdir($fullPath, 0755, true)) { + + // --- Create an empty metadata file for the new folder --- + // Helper: Generate the metadata file path for a given folder. + // For "root", returns "root_metadata.json". Otherwise, replaces slashes, backslashes, and spaces with dashes and appends "_metadata.json". + function getMetadataFilePath($folder) { + if (strtolower($folder) === 'root' || $folder === '') { + return META_DIR . "root_metadata.json"; + } + return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json'; + } + + $metadataFile = getMetadataFilePath($relativePath); + // Create an empty associative array (i.e. empty metadata) and write to the metadata file. + file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT)); + echo json_encode(['success' => true]); } else { echo json_encode(['success' => false, 'error' => 'Failed to create folder.']); diff --git a/createShareLink.php b/createShareLink.php new file mode 100644 index 0000000..d506b75 --- /dev/null +++ b/createShareLink.php @@ -0,0 +1,59 @@ + "Invalid input."]); + exit; +} + +$folder = isset($input['folder']) ? trim($input['folder']) : ""; +$file = isset($input['file']) ? basename($input['file']) : ""; +$expirationMinutes = isset($input['expirationMinutes']) ? intval($input['expirationMinutes']) : 60; +$password = isset($input['password']) ? $input['password'] : ""; + +// Validate folder using regex. +if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) { + echo json_encode(["error" => "Invalid folder name."]); + exit; +} + +// Optionally, you could check if the file exists in the uploads directory here. + +// Generate a secure token. +$token = bin2hex(random_bytes(4)); // 8 hex characters. + +// Calculate expiration (Unix timestamp). +$expires = time() + ($expirationMinutes * 60); + +// Hash password if provided. +$hashedPassword = !empty($password) ? password_hash($password, PASSWORD_DEFAULT) : ""; + +// File to store share links. +$shareFile = META_DIR . "share_links.json"; +$shareLinks = []; +if (file_exists($shareFile)) { + $data = file_get_contents($shareFile); + $shareLinks = json_decode($data, true); + if (!is_array($shareLinks)) { + $shareLinks = []; + } +} + +// Add record. +$shareLinks[$token] = [ + "folder" => $folder, + "file" => $file, + "expires" => $expires, + "password" => $hashedPassword +]; + +// Save the share links. +if (file_put_contents($shareFile, json_encode($shareLinks, JSON_PRETTY_PRINT))) { + echo json_encode(["token" => $token, "expires" => $expires]); +} else { + echo json_encode(["error" => "Could not save share link."]); +} +?> \ No newline at end of file diff --git a/deleteFiles.php b/deleteFiles.php index 8e272c8..6921f93 100644 --- a/deleteFiles.php +++ b/deleteFiles.php @@ -19,6 +19,15 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { exit; } +// Helper: Generate the metadata file path for a given folder. +// For "root", returns "root_metadata.json". Otherwise, replaces slashes, backslashes, and spaces with dashes and appends "_metadata.json". +function getMetadataFilePath($folder) { + if (strtolower($folder) === 'root' || $folder === '') { + return META_DIR . "root_metadata.json"; + } + return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json'; +} + // Read request body $data = json_decode(file_get_contents("php://input"), true); @@ -50,7 +59,7 @@ $deletedFiles = []; $errors = []; // Define a safe file name pattern: allow letters, numbers, underscores, dashes, dots, and spaces. -$safeFileNamePattern = '/^[A-Za-z0-9_\-\. ]+$/'; +$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/'; foreach ($data['files'] as $fileName) { $basename = basename(trim($fileName)); @@ -65,13 +74,27 @@ foreach ($data['files'] as $fileName) { if (file_exists($filePath)) { if (unlink($filePath)) { - $deletedFiles[] = $fileName; + $deletedFiles[] = $basename; } else { - $errors[] = "Failed to delete $fileName"; + $errors[] = "Failed to delete $basename"; } } else { // Consider file already deleted. - $deletedFiles[] = $fileName; + $deletedFiles[] = $basename; + } +} + +// Update folder-specific metadata file by removing deleted files. +$metadataFile = getMetadataFilePath($folder); +if (file_exists($metadataFile)) { + $metadata = json_decode(file_get_contents($metadataFile), true); + if (is_array($metadata)) { + foreach ($deletedFiles as $delFile) { + if (isset($metadata[$delFile])) { + unset($metadata[$delFile]); + } + } + file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT)); } } diff --git a/deleteFolder.php b/deleteFolder.php index d34792a..b0ed663 100644 --- a/deleteFolder.php +++ b/deleteFolder.php @@ -60,8 +60,28 @@ if (count(scandir($folderPath)) > 2) { exit; } -// Attempt to delete the folder +/** + * Helper: Generate the metadata file path for a given folder. + * For "root", returns "root_metadata.json". Otherwise, it replaces + * slashes, backslashes, and spaces with dashes and appends "_metadata.json". + * + * @param string $folder The folder's relative path. + * @return string The full path to the folder's metadata file. + */ +function getMetadataFilePath($folder) { + if (strtolower($folder) === 'root' || $folder === '') { + return META_DIR . "root_metadata.json"; + } + return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json'; +} + +// Attempt to delete the folder. if (rmdir($folderPath)) { + // Remove corresponding metadata file if it exists. + $metadataFile = getMetadataFilePath($folderName); + if (file_exists($metadataFile)) { + unlink($metadataFile); + } echo json_encode(['success' => true]); } else { echo json_encode(['success' => false, 'error' => 'Failed to delete folder.']); diff --git a/domUtils.js b/domUtils.js index e3e89c0..6bb65ea 100644 --- a/domUtils.js +++ b/domUtils.js @@ -141,30 +141,44 @@ export function buildFileTableRow(file, folderPath) { } else if (/\.pdf$/i.test(file.name)) { previewIcon = `picture_as_pdf`; } - previewButton = ``; } return ` - - - - - ${safeFileName} - ${safeModified} - ${safeUploaded} - ${safeSize} - ${safeUploader} - -
- Download - ${file.editable ? `` : ""} - ${previewButton} - -
- - + + + + + ${safeFileName} + ${safeModified} + ${safeUploaded} + ${safeSize} + ${safeUploader} + +
+ + file_download + + ${file.editable ? ` + + ` : ""} + ${previewButton} + +
+ + `; } diff --git a/download.php b/download.php new file mode 100644 index 0000000..b6452cd --- /dev/null +++ b/download.php @@ -0,0 +1,59 @@ + "Unauthorized"]); + exit; +} + +// Get file parameters from the GET request. +$file = isset($_GET['file']) ? basename($_GET['file']) : ''; +$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; + +// Validate file name (allowing letters, numbers, underscores, dashes, dots, and parentheses) +if (!preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $file)) { + http_response_code(400); + echo json_encode(["error" => "Invalid file name."]); + exit; +} + +// Determine the directory. +if ($folder !== 'root') { + $directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR; +} else { + $directory = UPLOAD_DIR; +} + +$filePath = $directory . $file; + +if (!file_exists($filePath)) { + http_response_code(404); + echo json_encode(["error" => "File not found."]); + exit; +} + +// Serve the file. +$mimeType = mime_content_type($filePath); +header("Content-Type: " . $mimeType); + +// For images, serve inline; for other types, force download. +$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); +if (in_array($ext, ['jpg','jpeg','png','gif','bmp','webp','svg','ico'])) { + header('Content-Disposition: inline; filename="' . basename($filePath) . '"'); +} else { + header('Content-Disposition: attachment; filename="' . basename($filePath) . '"'); +} +header('Content-Length: ' . filesize($filePath)); + +// Disable caching. +header('Cache-Control: no-store, no-cache, must-revalidate'); +header('Pragma: no-cache'); + +readfile($filePath); +exit; +?> \ No newline at end of file diff --git a/downloadZip.php b/downloadZip.php index 684cbb3..714af1d 100644 --- a/downloadZip.php +++ b/downloadZip.php @@ -38,7 +38,7 @@ $files = $data['files']; if ($folder !== "root") { $parts = explode('/', $folder); foreach ($parts as $part) { - if (empty($part) || $part === '.' || $part === '..' || !preg_match('/^[A-Za-z0-9_\-. ]+$/', $part)) { + if (empty($part) || $part === '.' || $part === '..' || !preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $part)) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "Invalid folder name."]); @@ -76,7 +76,7 @@ if (empty($files)) { } foreach ($files as $fileName) { - if (!preg_match('/^[A-Za-z0-9_\-. ]+$/', $fileName)) { + if (!preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $fileName)) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "Invalid file name: " . $fileName]); @@ -119,10 +119,14 @@ foreach ($filesToZip as $filePath) { } $zip->close(); -// Serve the ZIP file. +// Send headers to force download and disable caching. header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="files.zip"'); header('Content-Length: ' . filesize($tempZip)); +header('Cache-Control: no-store, no-cache, must-revalidate'); +header('Pragma: no-cache'); + +// Output the file and delete it afterward. readfile($tempZip); unlink($tempZip); exit; diff --git a/fileManager.js b/fileManager.js index ba47ed9..dc1d607 100644 --- a/fileManager.js +++ b/fileManager.js @@ -9,7 +9,7 @@ import { showToast, updateRowHighlight, toggleRowSelection, - previewFile + previewFile as originalPreviewFile } from './domUtils.js'; export let fileData = []; @@ -17,8 +17,37 @@ export let sortOrder = { column: "uploaded", ascending: true }; window.itemsPerPage = window.itemsPerPage || 10; window.currentPage = window.currentPage || 1; +window.viewMode = localStorage.getItem("viewMode") || "table"; // "table" or "gallery" + +// ============================== +// VIEW MODE TOGGLE BUTTON +// ============================== +function createViewToggleButton() { + let toggleBtn = document.getElementById("toggleViewBtn"); + if (!toggleBtn) { + toggleBtn = document.createElement("button"); + toggleBtn.id = "toggleViewBtn"; + toggleBtn.classList.add("btn", "btn-secondary"); + const titleElem = document.getElementById("fileListTitle"); + if (titleElem) { + titleElem.parentNode.insertBefore(toggleBtn, titleElem.nextSibling); + } + } + toggleBtn.textContent = window.viewMode === "gallery" ? "Switch to Table View" : "Switch to Gallery View"; + toggleBtn.onclick = () => { + window.viewMode = window.viewMode === "gallery" ? "table" : "gallery"; + localStorage.setItem("viewMode", window.viewMode); + loadFileList(window.currentFolder); + toggleBtn.textContent = window.viewMode === "gallery" ? "Switch to Table View" : "Switch to Gallery View"; + }; + return toggleBtn; +} +window.createViewToggleButton = createViewToggleButton; + +// ----------------------------- +// Helper: formatFolderName +// ----------------------------- -// --- Define formatFolderName --- function formatFolderName(folder) { if (folder === "root") return "(Root)"; return folder @@ -26,11 +55,224 @@ function formatFolderName(folder) { .replace(/\b\w/g, char => char.toUpperCase()); } -// Expose DOM helper functions for inline handlers. +// Expose inline DOM helpers. window.toggleRowSelection = toggleRowSelection; window.updateRowHighlight = updateRowHighlight; -window.previewFile = previewFile; +// ============================================== +// FEATURE: Public File Sharing Modal +// ============================================== + +function openShareModal(file, folder) { + const existing = document.getElementById("shareModal"); + if (existing) existing.remove(); + + const modal = document.createElement("div"); + modal.id = "shareModal"; + modal.classList.add("modal"); + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + modal.style.display = "block"; + + document.getElementById("closeShareModal").addEventListener("click", () => { + modal.remove(); + }); + + document.getElementById("generateShareLinkBtn").addEventListener("click", () => { + const expiration = document.getElementById("shareExpiration").value; + const password = document.getElementById("sharePassword").value; + fetch("createShareLink.php", { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": window.csrfToken + }, + body: JSON.stringify({ + folder: folder, + file: file.name, + expirationMinutes: parseInt(expiration), + password: password + }) + }) + .then(response => response.json()) + .then(data => { + if (data.token) { + // Get the share endpoint from the meta tag (or fallback to a global variable) + let shareEndpoint = document.querySelector('meta[name="share-url"]') + ? document.querySelector('meta[name="share-url"]').getAttribute('content') + : (window.SHARE_URL || "share.php"); + const shareUrl = `${shareEndpoint}?token=${encodeURIComponent(data.token)}`; + const displayDiv = document.getElementById("shareLinkDisplay"); + const inputField = document.getElementById("shareLinkInput"); + inputField.value = shareUrl; + displayDiv.style.display = "block"; + } else { + showToast("Error generating share link: " + (data.error || "Unknown error")); + } + }) + .catch(err => { + console.error("Error generating share link:", err); + showToast("Error generating share link."); + }); + }); + + document.getElementById("copyShareLinkBtn").addEventListener("click", () => { + const input = document.getElementById("shareLinkInput"); + input.select(); + document.execCommand("copy"); + showToast("Link copied to clipboard!"); + }); +} + +// ============================================== +// FEATURE: Enhanced Preview Modal with Navigation +// ============================================= +// This function replaces the previous preview behavior for images. +// It uses your original modal layout and, if multiple images exist, +// overlays transparent Prev/Next buttons over the image. +function enhancedPreviewFile(fileUrl, fileName) { + let modal = document.getElementById("filePreviewModal"); + if (!modal) { + modal = document.createElement("div"); + modal.id = "filePreviewModal"; + Object.assign(modal.style, { + position: "fixed", + top: "0", + left: "0", + width: "100vw", + height: "100vh", + backgroundColor: "rgba(0,0,0,0.7)", + display: "flex", + justifyContent: "center", + alignItems: "center", + zIndex: "1000" + }); + modal.innerHTML = ` + `; + document.body.appendChild(modal); + + document.getElementById("closeFileModal").addEventListener("click", function () { + modal.style.display = "none"; + }); + modal.addEventListener("click", function (e) { + if (e.target === modal) { + modal.style.display = "none"; + } + }); + } + modal.querySelector("h4").textContent = fileName; + const container = modal.querySelector(".file-preview-container"); + container.innerHTML = ""; + + const extension = fileName.split('.').pop().toLowerCase(); + const isImage = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(fileName); + if (isImage) { + const img = document.createElement("img"); + img.src = fileUrl; + img.className = "image-modal-img"; + img.style.maxWidth = "80vw"; + img.style.maxHeight = "80vh"; + container.appendChild(img); + + // If multiple images exist, add arrow navigation. + const images = fileData.filter(file => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)); + if (images.length > 1) { + modal.galleryImages = images; + modal.galleryCurrentIndex = images.findIndex(f => f.name === fileName); + + const prevBtn = document.createElement("button"); + prevBtn.textContent = "‹"; + prevBtn.className = "gallery-nav-btn"; + prevBtn.style.cssText = "position: absolute; top: 50%; left: 10px; transform: translateY(-50%); background: transparent; border: none; color: white; font-size: 48px; cursor: pointer;"; + prevBtn.addEventListener("click", function (e) { + e.stopPropagation(); + modal.galleryCurrentIndex = (modal.galleryCurrentIndex - 1 + modal.galleryImages.length) % modal.galleryImages.length; + let newFile = modal.galleryImages[modal.galleryCurrentIndex]; + modal.querySelector("h4").textContent = newFile.name; + img.src = ((window.currentFolder === "root") + ? "uploads/" + : "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/") + + encodeURIComponent(newFile.name) + "?t=" + new Date().getTime(); + }); + const nextBtn = document.createElement("button"); + nextBtn.textContent = "›"; + nextBtn.className = "gallery-nav-btn"; + nextBtn.style.cssText = "position: absolute; top: 50%; right: 10px; transform: translateY(-50%); background: transparent; border: none; color: white; font-size: 48px; cursor: pointer;"; + nextBtn.addEventListener("click", function (e) { + e.stopPropagation(); + modal.galleryCurrentIndex = (modal.galleryCurrentIndex + 1) % modal.galleryImages.length; + let newFile = modal.galleryImages[modal.galleryCurrentIndex]; + modal.querySelector("h4").textContent = newFile.name; + img.src = ((window.currentFolder === "root") + ? "uploads/" + : "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/") + + encodeURIComponent(newFile.name) + "?t=" + new Date().getTime(); + }); + container.appendChild(prevBtn); + container.appendChild(nextBtn); + } + } else { + if (extension === "pdf") { + const embed = document.createElement("embed"); + const separator = fileUrl.indexOf('?') === -1 ? '?' : '&'; + embed.src = fileUrl + separator + 't=' + new Date().getTime(); + embed.type = "application/pdf"; + embed.style.width = "80vw"; + embed.style.height = "80vh"; + embed.style.border = "none"; + container.appendChild(embed); + } else if (/\.(mp4|webm|mov|ogg)$/i.test(fileName)) { + const video = document.createElement("video"); + video.src = fileUrl; + video.controls = true; + video.className = "image-modal-img"; + container.appendChild(video); + } else { + container.textContent = "Preview not available for this file type."; + } + } + modal.style.display = "flex"; +} + +export function previewFile(fileUrl, fileName) { + enhancedPreviewFile(fileUrl, fileName); +} + +// ============================================== +// ORIGINAL FILE MANAGER FUNCTIONS +// ============================================== export function loadFileList(folderParam) { const folder = folderParam || "root"; const fileListContainer = document.getElementById("fileList"); @@ -39,7 +281,17 @@ export function loadFileList(folderParam) { fileListContainer.innerHTML = "
Loading files...
"; return fetch("getFileList.php?folder=" + encodeURIComponent(folder) + "&recursive=1&t=" + new Date().getTime()) - .then(response => response.json()) + .then(response => { + // Check if the session has expired. + if (response.status === 401) { + showToast("Session expired. Please log in again."); + // Redirect to logout.php to clear the session; this can trigger a login process. + window.location.href = "logout.php"; + // Throw error to stop further processing. + throw new Error("Unauthorized"); + } + return response.json(); + }) .then(data => { fileListContainer.innerHTML = ""; if (data.files && data.files.length > 0) { @@ -47,10 +299,17 @@ export function loadFileList(folderParam) { file.fullName = (file.path || file.name).trim().toLowerCase(); file.editable = canEditFile(file.name); file.folder = folder; + if (!file.type && /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) { + file.type = "image"; + } return file; }); fileData = data.files; - renderFileTable(folder); + if (window.viewMode === "gallery") { + renderGalleryView(folder); + } else { + renderFileTable(folder); + } } else { fileListContainer.textContent = "No files found."; updateFileActionButtons(); @@ -59,7 +318,10 @@ export function loadFileList(folderParam) { }) .catch(error => { console.error("Error loading file list:", error); - fileListContainer.textContent = "Error loading files."; + // Only update the container text if error is not due to an unauthorized response. + if (error.message !== "Unauthorized") { + fileListContainer.textContent = "Error loading files."; + } return []; }) .finally(() => { @@ -95,7 +357,8 @@ export function renderFileTable(folder) { searchTerm }); - const headerHTML = buildFileTableHeader(sortOrder); + let headerHTML = buildFileTableHeader(sortOrder); + // Do not add a separate share column; share button goes into the actions cell. const startIndex = (currentPage - 1) * itemsPerPageSetting; const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles); @@ -103,10 +366,15 @@ export function renderFileTable(folder) { let rowsHTML = ""; if (totalFiles > 0) { filteredFiles.slice(startIndex, endIndex).forEach(file => { - rowsHTML += buildFileTableRow(file, folderPath); + let rowHTML = buildFileTableRow(file, folderPath); + // Insert share button into the actions container. + rowHTML = rowHTML.replace(/(<\/div>\s*<\/td>\s*<\/tr>)/, `$1`); + rowsHTML += rowHTML; }); } else { - rowsHTML += `No files found.`; + rowsHTML += `No files found.`; } rowsHTML += ""; @@ -114,6 +382,8 @@ export function renderFileTable(folder) { fileListContainer.innerHTML = topControlsHTML + headerHTML + rowsHTML + bottomControlsHTML; + createViewToggleButton(); + const newSearchInput = document.getElementById("searchInput"); if (newSearchInput) { newSearchInput.addEventListener("input", debounce(function () { @@ -141,6 +411,78 @@ export function renderFileTable(folder) { }); }); + // Bind share button events in table view. + document.querySelectorAll(".share-btn").forEach(btn => { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + const fileName = this.getAttribute("data-file"); + const file = fileData.find(f => f.name === fileName); + if (file) { + openShareModal(file, folder); + } + }); + }); + + updateFileActionButtons(); +} + +export function renderGalleryView(folder) { + const fileListContainer = document.getElementById("fileList"); + const folderPath = (folder === "root") + ? "uploads/" + : "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/"; + // Use CSS Grid for gallery layout. + const gridStyle = "display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; padding: 10px;"; + let galleryHTML = `"; + fileListContainer.innerHTML = galleryHTML; + + document.querySelectorAll(".gallery-share-btn").forEach(btn => { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + const fileName = this.getAttribute("data-file"); + const folder = this.getAttribute("data-folder"); + const file = fileData.find(f => f.name === fileName); + if (file) { + openShareModal(file, folder); + } + }); + }); + updateFileActionButtons(); } @@ -167,7 +509,11 @@ export function sortFiles(column, folder) { if (valA > valB) return sortOrder.ascending ? 1 : -1; return 0; }); - renderFileTable(folder); + if (window.viewMode === "gallery") { + renderGalleryView(folder); + } else { + renderFileTable(folder); + } } function parseCustomDate(dateStr) { @@ -355,8 +701,12 @@ export function handleCopySelected(e) { export async function loadCopyMoveFolderListForModal(dropdownId) { try { const response = await fetch('getFolderList.php'); - const folders = await response.json(); - console.log('Folders fetched for modal:', folders); + let folders = await response.json(); + if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) { + folders = folders.map(item => item.folder); + } + folders = folders.filter(folder => folder !== "root"); + const folderSelect = document.getElementById(dropdownId); folderSelect.innerHTML = ''; const rootOption = document.createElement('option'); @@ -389,11 +739,11 @@ document.addEventListener("DOMContentLoaded", function () { confirmCopy.addEventListener("click", function () { const targetFolder = document.getElementById("copyTargetFolder").value; if (!targetFolder) { - showToast("Please select a target folder for copying.!", 5000); + showToast("Please select a target folder for copying.", 5000); return; } if (targetFolder === window.currentFolder) { - showToast("Error: Cannot move files to the same folder."); + showToast("Error: Cannot copy files to the same folder."); return; } fetch("copyFiles.php", { @@ -403,7 +753,11 @@ document.addEventListener("DOMContentLoaded", function () { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, - body: JSON.stringify({ source: window.currentFolder, files: window.filesToCopy, destination: targetFolder }) + body: JSON.stringify({ + source: window.currentFolder, + files: window.filesToCopy, + destination: targetFolder + }) }) .then(response => response.json()) .then(data => { @@ -463,7 +817,11 @@ document.addEventListener("DOMContentLoaded", function () { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, - body: JSON.stringify({ source: window.currentFolder, files: window.filesToMove, destination: targetFolder }) + body: JSON.stringify({ + source: window.currentFolder, + files: window.filesToMove, + destination: targetFolder + }) }) .then(response => response.json()) .then(data => { @@ -660,7 +1018,7 @@ export function saveFile(fileName, folder) { export function displayFilePreview(file, container) { container.style.display = "inline-block"; - if (file.type.startsWith("image/")) { + if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) { const img = document.createElement("img"); img.src = URL.createObjectURL(file); img.classList.add("file-preview-img"); @@ -761,4 +1119,5 @@ window.changeItemsPerPage = function (newCount) { window.itemsPerPage = parseInt(newCount); window.currentPage = 1; renderFileTable(window.currentFolder); -}; \ No newline at end of file +}; +window.previewFile = previewFile; \ No newline at end of file diff --git a/folderManager.js b/folderManager.js index e4b11a3..89b6f3f 100644 --- a/folderManager.js +++ b/folderManager.js @@ -9,6 +9,7 @@ import { showToast, escapeHTML } from './domUtils.js'; // Formats a folder name for display (e.g. adding indentations). export function formatFolderName(folder) { + if (typeof folder !== "string") return ""; if (folder.indexOf("/") !== -1) { let parts = folder.split("/"); let indent = ""; @@ -25,6 +26,8 @@ export function formatFolderName(folder) { function buildFolderTree(folders) { const tree = {}; folders.forEach(folderPath => { + // Ensure folderPath is a string + if (typeof folderPath !== "string") return; const parts = folderPath.split('/'); let current = tree; parts.forEach(part => { @@ -119,9 +122,20 @@ export async function loadFolderTree(selectedFolder) { const response = await fetch('getFolderList.php'); if (response.status === 401) { console.error("Unauthorized: Please log in to view folders."); + showToast("Session expired. Please log in again."); + // Redirect to logout.php to clear the session; this can trigger a login process. + window.location.href = "logout.php"; return; } - const folders = await response.json(); + let folders = await response.json(); + + // If returned items are objects (with a "folder" property), extract folder paths. + if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) { + folders = folders.map(item => item.folder); + } + // Filter out duplicate "root" entries if present. + folders = folders.filter(folder => folder !== "root"); + if (!Array.isArray(folders)) { console.error("Folder list response is not an array:", folders); return; @@ -294,7 +308,7 @@ document.getElementById("submitRenameFolder").addEventListener("click", function "Content-Type": "application/json", "X-CSRF-Token": csrfToken }, - body: JSON.stringify({ oldFolder: selectedFolder, newFolder: newFolderFull }) + body: JSON.stringify({ oldFolder: window.currentFolder, newFolder: newFolderFull }) }) .then(response => response.json()) .then(data => { diff --git a/getFileList.php b/getFileList.php index 793c0e3..b173c6c 100644 --- a/getFileList.php +++ b/getFileList.php @@ -26,7 +26,22 @@ if ($folder !== 'root') { $directory = UPLOAD_DIR; } -$metadataFile = META_DIR . META_FILE; +/** + * Helper: Generate the metadata file path for a given folder. + * For "root", returns "root_metadata.json". Otherwise, replaces slashes, + * backslashes, and spaces with dashes and appends "_metadata.json". + * + * @param string $folder The folder's relative path. + * @return string The full path to the folder's metadata file. + */ +function getMetadataFilePath($folder) { + if (strtolower($folder) === 'root' || $folder === '') { + return META_DIR . "root_metadata.json"; + } + return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json'; +} + +$metadataFile = getMetadataFilePath($folder); $metadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : []; if (!is_dir($directory)) { @@ -37,10 +52,15 @@ if (!is_dir($directory)) { $files = array_values(array_diff(scandir($directory), array('.', '..'))); $fileList = []; -// Define a safe file name pattern: letters, numbers, underscores, dashes, dots, and spaces. -$safeFileNamePattern = '/^[A-Za-z0-9_\-\. ]+$/'; +// Define a safe file name pattern: letters, numbers, underscores, dashes, dots, parentheses, and spaces. +$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/'; foreach ($files as $file) { + // Skip hidden files (those that begin with a dot) + if (substr($file, 0, 1) === '.') { + continue; + } + $filePath = $directory . DIRECTORY_SEPARATOR . $file; // Only include files (skip directories) if (!is_file($filePath)) continue; @@ -50,8 +70,8 @@ foreach ($files as $file) { continue; } - // Build the metadata key; if not in root, include the folder path. - $metaKey = ($folder !== 'root') ? $folder . "/" . $file : $file; + // Since metadata is stored per folder, the key is simply the file name. + $metaKey = $file; $fileDateModified = filemtime($filePath) ? date(DATE_TIME_FORMAT, filemtime($filePath)) : "Unknown"; $fileUploadedDate = isset($metadata[$metaKey]["uploaded"]) ? $metadata[$metaKey]["uploaded"] : "Unknown"; diff --git a/getFolderList.php b/getFolderList.php index e10c4e3..36d802b 100644 --- a/getFolderList.php +++ b/getFolderList.php @@ -23,7 +23,6 @@ function getSubfolders($dir, $relative = '') { $safeFolderNamePattern = '/^[A-Za-z0-9_\- ]+$/'; foreach ($items as $item) { if ($item === '.' || $item === '..') continue; - // Only process folder names that match the safe pattern. if (!preg_match($safeFolderNamePattern, $item)) { continue; } @@ -40,12 +39,59 @@ function getSubfolders($dir, $relative = '') { return $folders; } -$baseDir = rtrim(UPLOAD_DIR, '/\\'); -$folderList = []; - -if (is_dir($baseDir)) { - $folderList = getSubfolders($baseDir); +/** + * Helper: Generate the metadata file path for a given folder. + * For "root", it returns "root_metadata.json"; otherwise, it replaces + * slashes, backslashes, and spaces with dashes and appends "_metadata.json". + * + * @param string $folder The folder's relative path. + * @return string The full path to the folder's metadata file. + */ +function getMetadataFilePath($folder) { + if (strtolower($folder) === 'root' || $folder === '') { + return META_DIR . "root_metadata.json"; + } + return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json'; } -echo json_encode($folderList); +$baseDir = rtrim(UPLOAD_DIR, '/\\'); + +// Build an array to hold folder information. +$folderInfoList = []; + +// Include "root" as a folder. +$rootMetaFile = getMetadataFilePath('root'); +$rootFileCount = 0; +if (file_exists($rootMetaFile)) { + $rootMetadata = json_decode(file_get_contents($rootMetaFile), true); + $rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0; +} +$folderInfoList[] = [ + "folder" => "root", + "fileCount" => $rootFileCount, + "metadataFile" => basename($rootMetaFile) +]; + +// Scan for subfolders. +$subfolders = []; +if (is_dir($baseDir)) { + $subfolders = getSubfolders($baseDir); +} + +// For each subfolder, load its metadata and record file count. +foreach ($subfolders as $folder) { + $metaFile = getMetadataFilePath($folder); + $fileCount = 0; + if (file_exists($metaFile)) { + $metadata = json_decode(file_get_contents($metaFile), true); + $fileCount = is_array($metadata) ? count($metadata) : 0; + } + $folderInfoList[] = [ + "folder" => $folder, + "fileCount" => $fileCount, + "metadataFile" => basename($metaFile) + ]; +} + +echo json_encode($folderInfoList); ?> \ No newline at end of file diff --git a/index.html b/index.html index f11b5fb..10adfaf 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,8 @@ Multi File Upload Editor - + + @@ -174,7 +175,7 @@