diff --git a/README.md b/README.md index 43f08a2..6cf5ccd 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,26 @@ Multi File Upload Editor is a lightweight, secure web application for uploading, - 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. +- **Trash Management with Restore & Delete:** + - **Trash Storage & Metadata:** + - Deleted files are moved to a designated “Trash” folder rather than being immediately removed. + - Metadata is stored in a JSON file (`trash.json`) that records: + - Original folder and file name + - Timestamp when the file was trashed + - Uploader information (and optionally who deleted it) + - Additional metadata (e.g., file type) + - **Restore Functionality:** + - Admins can view trashed files in a modal. + - They can restore individual files (with conflict checks) or restore all files back to their original location. + - **Delete Functionality:** + - Users can permanently delete trashed files via: + - **Delete Selected:** Remove specific files from the Trash and update `trash.json`. + - **Delete All:** Permanently remove every file from the Trash after confirmation. + - **Auto-Purge Mechanism:** + - The system automatically purges (permanently deletes) any files in the Trash older than three days, helping manage storage and prevent the accumulation of outdated files. + - **User Interface:** + - The trash modal displays details such as file name, uploader/deleter, and the trashed date/time. + - Material icons with tooltips visually represent the restore and delete actions. --- diff --git a/addUser.php b/addUser.php index b11a3fe..6d3cd5d 100644 --- a/addUser.php +++ b/addUser.php @@ -3,13 +3,6 @@ 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; -} // Determine if we are in setup mode: // - Query parameter setup=1 is passed @@ -20,7 +13,14 @@ if ($isSetup && (!file_exists($usersFile) || trim(file_get_contents($usersFile)) $setupMode = true; } else { $setupMode = false; - // Only allow admins to add users normally. + // In non-setup mode, check CSRF token and require admin privileges. + $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; + } if ( !isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true || !isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true @@ -83,4 +83,4 @@ if ($setupMode) { } echo json_encode(["success" => "User added successfully"]); -?> +?> \ No newline at end of file diff --git a/auth.js b/auth.js index 142d177..2ed33f1 100644 --- a/auth.js +++ b/auth.js @@ -1,35 +1,107 @@ import { sendRequest } from './networkUtils.js'; import { toggleVisibility, showToast } from './domUtils.js'; -// Import loadFileList and renderFileTable from fileManager.js to refresh the file list upon login. import { loadFileList, renderFileTable, displayFilePreview, initFileActions } from './fileManager.js'; import { loadFolderTree } from './folderManager.js'; function initAuth() { // First, check if the user is already authenticated. - checkAuthentication(); + checkAuthentication(false).then(data => { + if (data.setup) { + window.setupMode = true; + showToast("Setup mode: No users found. Please add an admin user."); + toggleVisibility("loginForm", false); + toggleVisibility("mainOperations", false); + document.querySelector(".header-buttons").style.visibility = "hidden"; + toggleVisibility("addUserModal", true); + return; + } + window.setupMode = false; + if (data.authenticated) { + // User is logged in—show the main UI. + toggleVisibility("loginForm", false); + toggleVisibility("mainOperations", true); + toggleVisibility("uploadFileForm", true); + toggleVisibility("fileListContainer", true); + document.querySelector(".header-buttons").style.visibility = "visible"; + // If admin, show admin-only buttons. + if (data.isAdmin) { + const addUserBtn = document.getElementById("addUserBtn"); + const removeUserBtn = document.getElementById("removeUserBtn"); + if (addUserBtn) addUserBtn.style.display = "block"; + if (removeUserBtn) removeUserBtn.style.display = "block"; + // Create and show the restore button. + let restoreBtn = document.getElementById("restoreFilesBtn"); + if (!restoreBtn) { + restoreBtn = document.createElement("button"); + restoreBtn.id = "restoreFilesBtn"; + restoreBtn.classList.add("btn", "btn-warning"); + // Use a material icon. + restoreBtn.innerHTML = 'restore_from_trash'; - // Attach event listener for login. - document.getElementById("authForm").addEventListener("submit", function (event) { - event.preventDefault(); - const formData = { - username: document.getElementById("loginUsername").value.trim(), - password: document.getElementById("loginPassword").value.trim() - }; - // Include CSRF token header with login - sendRequest("auth.php", "POST", formData, { "X-CSRF-Token": window.csrfToken }) - .then(data => { - if (data.success) { - console.log("✅ Login successful. Reloading page."); - sessionStorage.setItem("welcomeMessage", "Welcome back, " + formData.username + "!"); - window.location.reload(); - } else { - showToast("Login failed: " + (data.error || "Unknown error")); + const headerButtons = document.querySelector(".header-buttons"); + if (headerButtons) { + // Insert after the third child if available. + if (headerButtons.children.length >= 4) { + headerButtons.insertBefore(restoreBtn, headerButtons.children[4]); + } else { + headerButtons.appendChild(restoreBtn); + } + } } - }) - .catch(error => console.error("❌ Error logging in:", error)); + restoreBtn.style.display = "block"; + } else { + const addUserBtn = document.getElementById("addUserBtn"); + const removeUserBtn = document.getElementById("removeUserBtn"); + if (addUserBtn) addUserBtn.style.display = "none"; + if (removeUserBtn) removeUserBtn.style.display = "none"; + // If not admin, hide the restore button. + const restoreBtn = document.getElementById("restoreFilesBtn"); + if (restoreBtn) { + restoreBtn.style.display = "none"; + } + } + // Set items-per-page. + const selectElem = document.querySelector(".form-control.bottom-select"); + if (selectElem) { + const stored = localStorage.getItem("itemsPerPage") || "10"; + selectElem.value = stored; + } + } else { + // Do not show a toast message repeatedly during initial check. + toggleVisibility("loginForm", true); + toggleVisibility("mainOperations", false); + toggleVisibility("uploadFileForm", false); + toggleVisibility("fileListContainer", false); + document.querySelector(".header-buttons").style.visibility = "hidden"; + } + }).catch(error => { + console.error("Error checking authentication:", error); }); - // Set up the logout button. + // Attach login event listener once. + const authForm = document.getElementById("authForm"); + if (authForm) { + authForm.addEventListener("submit", function (event) { + event.preventDefault(); + const formData = { + username: document.getElementById("loginUsername").value.trim(), + password: document.getElementById("loginPassword").value.trim() + }; + sendRequest("auth.php", "POST", formData, { "X-CSRF-Token": window.csrfToken }) + .then(data => { + if (data.success) { + console.log("✅ Login successful. Reloading page."); + sessionStorage.setItem("welcomeMessage", "Welcome back, " + formData.username + "!"); + window.location.reload(); + } else { + showToast("Login failed: " + (data.error || "Unknown error")); + } + }) + .catch(error => console.error("❌ Error logging in:", error)); + }); + } + + // Attach logout event listener. document.getElementById("logoutBtn").addEventListener("click", function () { fetch("logout.php", { method: "POST", @@ -40,12 +112,11 @@ function initAuth() { .catch(error => console.error("Logout error:", error)); }); - // Set up Add User functionality. + // Add User functionality. document.getElementById("addUserBtn").addEventListener("click", function () { resetUserForm(); toggleVisibility("addUserModal", true); }); - document.getElementById("saveUserBtn").addEventListener("click", function () { const newUsername = document.getElementById("newUsername").value.trim(); const newPassword = document.getElementById("newPassword").value.trim(); @@ -72,24 +143,22 @@ function initAuth() { if (data.success) { showToast("User added successfully!"); closeAddUserModal(); - checkAuthentication(); + checkAuthentication(false); // Re-check without showing toast } else { showToast("Error: " + (data.error || "Could not add user")); } }) .catch(error => console.error("Error adding user:", error)); }); - document.getElementById("cancelUserBtn").addEventListener("click", function () { closeAddUserModal(); }); - // Set up Remove User functionality. + // Remove User functionality. document.getElementById("removeUserBtn").addEventListener("click", function () { loadUserList(); toggleVisibility("removeUserModal", true); }); - document.getElementById("deleteUserBtn").addEventListener("click", function () { const selectElem = document.getElementById("removeUsernameSelect"); const usernameToRemove = selectElem.value; @@ -121,52 +190,29 @@ function initAuth() { }) .catch(error => console.error("Error removing user:", error)); }); - document.getElementById("cancelRemoveUserBtn").addEventListener("click", function () { closeRemoveUserModal(); }); } -function checkAuthentication() { - // Return the promise from sendRequest +function checkAuthentication(showLoginToast = true) { + // Optionally pass a flag so we don't show a toast every time. return sendRequest("checkAuth.php") .then(data => { if (data.setup) { window.setupMode = true; - showToast("Setup mode: No users found. Please add an admin user."); + if (showLoginToast) showToast("Setup mode: No users found. Please add an admin user."); toggleVisibility("loginForm", false); toggleVisibility("mainOperations", false); document.querySelector(".header-buttons").style.visibility = "hidden"; toggleVisibility("addUserModal", true); return false; - } else { - window.setupMode = false; } + window.setupMode = false; if (data.authenticated) { - toggleVisibility("loginForm", false); - toggleVisibility("mainOperations", true); - toggleVisibility("uploadFileForm", true); - toggleVisibility("fileListContainer", true); - if (data.isAdmin) { - const addUserBtn = document.getElementById("addUserBtn"); - const removeUserBtn = document.getElementById("removeUserBtn"); - if (addUserBtn) addUserBtn.style.display = "block"; - if (removeUserBtn) removeUserBtn.style.display = "block"; - } else { - const addUserBtn = document.getElementById("addUserBtn"); - const removeUserBtn = document.getElementById("removeUserBtn"); - if (addUserBtn) addUserBtn.style.display = "none"; - if (removeUserBtn) removeUserBtn.style.display = "none"; - } - document.querySelector(".header-buttons").style.visibility = "visible"; - const selectElem = document.querySelector(".form-control.bottom-select"); - if (selectElem) { - const stored = localStorage.getItem("itemsPerPage") || "10"; - selectElem.value = stored; - } - return true; + return data; } else { - showToast("Please log in to continue."); + if (showLoginToast) showToast("Please log in to continue."); toggleVisibility("loginForm", true); toggleVisibility("mainOperations", false); toggleVisibility("uploadFileForm", false); @@ -182,11 +228,7 @@ function checkAuthentication() { } window.checkAuthentication = checkAuthentication; -/* ------------------------------ - Persistent Items-Per-Page Setting - ------------------------------ */ window.changeItemsPerPage = function (value) { - console.log("Saving itemsPerPage:", value); localStorage.setItem("itemsPerPage", value); const folder = window.currentFolder || "root"; if (typeof renderFileTable === "function") { @@ -198,14 +240,10 @@ document.addEventListener("DOMContentLoaded", function () { const selectElem = document.querySelector(".form-control.bottom-select"); if (selectElem) { const stored = localStorage.getItem("itemsPerPage") || "10"; - console.log("Loaded itemsPerPage from localStorage:", stored); selectElem.value = stored; } }); -/* ------------------------------ - Helper functions for modals and user list - ------------------------------ */ function resetUserForm() { document.getElementById("newUsername").value = ""; document.getElementById("newPassword").value = ""; @@ -226,10 +264,6 @@ function loadUserList() { .then(response => response.json()) .then(data => { const users = Array.isArray(data) ? data : (data.users || []); - if (!users || !Array.isArray(users)) { - console.error("Invalid users data:", data); - return; - } const selectElem = document.getElementById("removeUsernameSelect"); selectElem.innerHTML = ""; users.forEach(user => { diff --git a/config.php b/config.php index 03447b1..1cc3889 100644 --- a/config.php +++ b/config.php @@ -50,6 +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'); - +define('TRASH_DIR', UPLOAD_DIR . 'trash/'); date_default_timezone_set(TIMEZONE); ?> \ No newline at end of file diff --git a/deleteFiles.php b/deleteFiles.php index 6921f93..a6f338f 100644 --- a/deleteFiles.php +++ b/deleteFiles.php @@ -19,8 +19,22 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { exit; } +// --- Setup Trash Folder & Metadata --- +$trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; +if (!file_exists($trashDir)) { + mkdir($trashDir, 0755, true); +} +$trashMetadataFile = $trashDir . "trash.json"; +$trashData = []; +if (file_exists($trashMetadataFile)) { + $json = file_get_contents($trashMetadataFile); + $trashData = json_decode($json, true); + if (!is_array($trashData)) { + $trashData = []; + } +} + // 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"; @@ -45,7 +59,6 @@ if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) { echo json_encode(["error" => "Invalid folder name."]); exit; } -// Trim any leading/trailing slashes and spaces. $folder = trim($folder, "/\\ "); // Build the upload directory. @@ -55,7 +68,17 @@ if ($folder !== 'root') { $uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR; } -$deletedFiles = []; +// Load folder metadata (if exists) to retrieve uploader and upload date. +$metadataFile = getMetadataFilePath($folder); +$folderMetadata = []; +if (file_exists($metadataFile)) { + $folderMetadata = json_decode(file_get_contents($metadataFile), true); + if (!is_array($folderMetadata)) { + $folderMetadata = []; + } +} + +$movedFiles = []; $errors = []; // Define a safe file name pattern: allow letters, numbers, underscores, dashes, dots, and spaces. @@ -73,23 +96,41 @@ foreach ($data['files'] as $fileName) { $filePath = $uploadDir . $basename; if (file_exists($filePath)) { - if (unlink($filePath)) { - $deletedFiles[] = $basename; + // Append a timestamp to the file name in trash to avoid collisions. + $timestamp = time(); + $trashFileName = $basename . "_" . $timestamp; + if (rename($filePath, $trashDir . $trashFileName)) { + $movedFiles[] = $basename; + // Record trash metadata for possible restoration. + $trashData[] = [ + 'type' => 'file', + 'originalFolder' => $uploadDir, // You could also store a relative path here. + 'originalName' => $basename, + 'trashName' => $trashFileName, + 'trashedAt' => $timestamp, + // Enrich trash record with uploader and upload date from folder metadata (if available) + 'uploaded' => isset($folderMetadata[$basename]['uploaded']) ? $folderMetadata[$basename]['uploaded'] : "Unknown", + 'uploader' => isset($folderMetadata[$basename]['uploader']) ? $folderMetadata[$basename]['uploader'] : "Unknown", + // NEW: Record the username of the user who deleted the file. + 'deletedBy' => isset($_SESSION['username']) ? $_SESSION['username'] : "Unknown" + ]; } else { - $errors[] = "Failed to delete $basename"; + $errors[] = "Failed to move $basename to Trash."; } } else { // Consider file already deleted. - $deletedFiles[] = $basename; + $movedFiles[] = $basename; } } +// Write back the updated trash metadata. +file_put_contents($trashMetadataFile, json_encode($trashData, JSON_PRETTY_PRINT)); + // 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) { + foreach ($movedFiles as $delFile) { if (isset($metadata[$delFile])) { unset($metadata[$delFile]); } @@ -99,8 +140,8 @@ if (file_exists($metadataFile)) { } if (empty($errors)) { - echo json_encode(["success" => "Files deleted: " . implode(", ", $deletedFiles)]); + echo json_encode(["success" => "Files moved to Trash: " . implode(", ", $movedFiles)]); } else { - echo json_encode(["error" => implode("; ", $errors) . ". Files deleted: " . implode(", ", $deletedFiles)]); + echo json_encode(["error" => implode("; ", $errors) . ". Files moved to Trash: " . implode(", ", $movedFiles)]); } ?> \ No newline at end of file diff --git a/deleteTrashFiles.php b/deleteTrashFiles.php new file mode 100644 index 0000000..800c0b6 --- /dev/null +++ b/deleteTrashFiles.php @@ -0,0 +1,105 @@ + "Invalid CSRF token"]); + http_response_code(403); + exit; +} + +// Ensure user is authenticated +if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + echo json_encode(["error" => "Unauthorized"]); + http_response_code(401); + exit; +} + +// --- Setup Trash Folder & Metadata --- +$trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; +if (!file_exists($trashDir)) { + mkdir($trashDir, 0755, true); +} +$trashMetadataFile = $trashDir . "trash.json"; + +// Load trash metadata into an associative array keyed by trashName. +$trashData = []; +if (file_exists($trashMetadataFile)) { + $json = file_get_contents($trashMetadataFile); + $tempData = json_decode($json, true); + if (is_array($tempData)) { + foreach ($tempData as $item) { + if (isset($item['trashName'])) { + $trashData[$item['trashName']] = $item; + } + } + } +} + +// Read request body. +$data = json_decode(file_get_contents("php://input"), true); +if (!$data) { + echo json_encode(["error" => "Invalid input"]); + exit; +} + +// Determine deletion mode: if "deleteAll" is true, delete all trash items; otherwise, use provided "files" array. +$filesToDelete = []; +if (isset($data['deleteAll']) && $data['deleteAll'] === true) { + $filesToDelete = array_keys($trashData); +} elseif (isset($data['files']) && is_array($data['files'])) { + $filesToDelete = $data['files']; +} else { + echo json_encode(["error" => "No trash file identifiers provided"]); + exit; +} + +$deletedFiles = []; +$errors = []; + +// Define a safe file name pattern. +$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/'; + +foreach ($filesToDelete as $trashName) { + $trashName = trim($trashName); + if (!preg_match($safeFileNamePattern, $trashName)) { + $errors[] = "$trashName has an invalid format."; + continue; + } + + if (!isset($trashData[$trashName])) { + $errors[] = "Trash item $trashName not found."; + continue; + } + + $filePath = $trashDir . $trashName; + + if (file_exists($filePath)) { + if (unlink($filePath)) { + $deletedFiles[] = $trashName; + unset($trashData[$trashName]); + } else { + $errors[] = "Failed to delete $trashName."; + } + } else { + // If the file doesn't exist, remove its metadata entry. + unset($trashData[$trashName]); + $deletedFiles[] = $trashName; + } +} + +// Write the updated trash metadata back (as an indexed array). +file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT)); + +if (empty($errors)) { + echo json_encode(["success" => "Trash items deleted: " . implode(", ", $deletedFiles)]); +} else { + echo json_encode(["error" => implode("; ", $errors) . ". Trash items deleted: " . implode(", ", $deletedFiles)]); +} +exit; +?> \ No newline at end of file diff --git a/domUtils.js b/domUtils.js index 6bb65ea..1ee5cfe 100644 --- a/domUtils.js +++ b/domUtils.js @@ -164,7 +164,7 @@ export function buildFileTableRow(file, folderPath) { file_download ${file.editable ? ` - ` : ""} @@ -999,9 +1005,11 @@ function getModeForFile(fileName) { function adjustEditorSize() { const modal = document.querySelector(".editor-modal"); if (modal && window.currentEditor) { - const modalHeight = modal.getBoundingClientRect().height || 600; - const newEditorHeight = Math.max(modalHeight * 0.8, 5) + "px"; - window.currentEditor.setSize("100%", newEditorHeight); + // Calculate available height for the editor. + // If you have a header or footer inside the modal, subtract their heights. + const headerHeight = 60; + const availableHeight = modal.clientHeight - headerHeight; + window.currentEditor.setSize("100%", availableHeight + "px"); } } diff --git a/folderManager.js b/folderManager.js index 9deeba7..2982759 100644 --- a/folderManager.js +++ b/folderManager.js @@ -68,6 +68,11 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") { const state = loadFolderTreeState(); let html = `