Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c8374a66c | ||
|
|
49138835ce | ||
|
|
c0dc0ce391 | ||
|
|
6426f4b924 | ||
|
|
b72356b657 | ||
|
|
fc45767712 | ||
|
|
1d5c6a48b5 | ||
|
|
772326c8e0 | ||
|
|
5892236aa9 | ||
|
|
0215bd3d76 | ||
|
|
a9c7bb6493 | ||
|
|
6d588eb143 | ||
|
|
2092512f43 | ||
|
|
833eaa3194 | ||
|
|
edb8ff476a | ||
|
|
2e55f5f4d7 | ||
|
|
ae48119e15 | ||
|
|
f5410a92e7 | ||
|
|
7898ad4f1c | ||
|
|
d3ce26e83d | ||
|
|
b4a903e738 | ||
|
|
53bb72f4ab | ||
|
|
cef96f0047 | ||
|
|
559df3c396 | ||
|
|
a24321455a | ||
|
|
3c2faa5218 |
69
README.md
69
README.md
@@ -1,19 +1,28 @@
|
||||
# Multi File Upload Editor
|
||||
# MFE - Lightweight Multi File Upload Editor
|
||||
|
||||
**Video demo:**
|
||||
|
||||
https://github.com/user-attachments/assets/179e6940-5798-4482-9a69-696f806c37de
|
||||
|
||||
**Dark mode:**
|
||||

|
||||
|
||||
changelogs available here: <https://github.com/error311/multi-file-upload-editor-docker/>
|
||||
|
||||
Multi File Upload Editor is a lightweight, secure, self-hosted web application for uploading, editing, and managing files. Built with an Apache/PHP backend and a modern JavaScript (ES6 modules) frontend, it offers a responsive, dynamic file management interface. It serves as an alternative to solutions like FileGator or ProjectSend, providing an easy-to-setup experience ideal for document management, image galleries, firmware file hosting, and more.
|
||||
MFE - Multi File Upload Editor is a lightweight, secure, self-hosted web application for uploading, syntax highlight editing, drag & drop and managing files. Built with an Apache/PHP backend and a modern JavaScript (ES6 modules) frontend, it offers a responsive, dynamic file management interface. It serves as an alternative to solutions like FileGator TinyFileManager or ProjectSend, providing an easy-to-setup experience ideal for document management, image galleries, firmware file hosting, and more.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -22,50 +31,80 @@ Multi File Upload Editor is a lightweight, secure, self-hosted web application f
|
||||
- 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.
|
||||
- **Enhanced File Editing Check:** Files with a Content-Length of 0 KB are now allowed to be edited.
|
||||
|
||||
- **Built-in File Preview:**
|
||||
- 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):**
|
||||
|
||||
- **Batch Operations (Delete/Copy/Move/Download/Extract Zip):**
|
||||
- **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.
|
||||
- **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.
|
||||
- **Extract Zip:** When one or more ZIP files are selected, users can extract the archive(s) directly into the current folder.
|
||||
- **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.
|
||||
- **Enhanced Context Menu & Keyboard Shortcuts:**
|
||||
- **Right-Click Context Menu:**
|
||||
- A custom context menu appears on right-clicking within the file list.
|
||||
- For multiple selections, options include Delete Selected, Copy Selected, Move Selected, Download Zip, and (if applicable) Extract Zip.
|
||||
- When exactly one file is selected, additional options (Preview, Edit [if editable], and Rename) are available.
|
||||
- **Keyboard Shortcut for Deletion:**
|
||||
- A global keydown listener detects Delete/Backspace key presses (when no input is focused) to trigger the delete operation.
|
||||
|
||||
- **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.
|
||||
- **Folder Manager Context Menu:**
|
||||
- Right-clicking on a folder (in the folder tree or breadcrumb) brings up a custom context menu with options for creating, renaming, and deleting folders.
|
||||
- **Keyboard Shortcut for Folder Deletion:**
|
||||
- A global key listener (Delete/Backspace) is provided to trigger folder deletion (with safeguards to prevent deleting the root folder).
|
||||
|
||||
- **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.
|
||||
|
||||
- **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 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.
|
||||
- Change password supported for all users.
|
||||
- **Persistent Login (Remember Me) with Encrypted Tokens:**
|
||||
- Users can remain logged in across sessions securely.
|
||||
- Persistent tokens are encrypted using AES‑256‑CBC before being stored in a JSON file.
|
||||
- On auto-login, the tokens are decrypted on the server to re-establish user sessions without requiring re-authentication.
|
||||
|
||||
- **Responsive, Dynamic & Persistent UI:**
|
||||
- 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.
|
||||
|
||||
- **Trash Management with Restore & Delete:**
|
||||
- **Trash Storage & Metadata:**
|
||||
- Deleted files are moved to a designated “Trash” folder rather than being immediately removed.
|
||||
@@ -91,22 +130,19 @@ Multi File Upload Editor is a lightweight, secure, self-hosted web application f
|
||||
|
||||
## Screenshots
|
||||
|
||||
**Light mode**
|
||||
**Light mode:**
|
||||

|
||||
|
||||
**Dark mode**
|
||||

|
||||
|
||||
**Dark editor**
|
||||
**Dark editor:**
|
||||

|
||||
|
||||
**Dark preview**
|
||||

|
||||
|
||||
**Restore or Delete Trash**
|
||||
**Restore or Delete Trash:**
|
||||

|
||||
|
||||
**Login page**
|
||||
**Login page:**
|
||||

|
||||
|
||||
**iphone screenshots:**
|
||||
@@ -208,6 +244,7 @@ For users who prefer containerization, a Docker image is available
|
||||
TIMEZONE: "America/New_York"
|
||||
TOTAL_UPLOAD_SIZE: "5G"
|
||||
SECURE: "false"
|
||||
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
|
||||
volumes:
|
||||
- /path/to/your/uploads:/var/www/uploads
|
||||
- /path/to/your/users:/var/www/users
|
||||
@@ -239,7 +276,7 @@ The `config.php` file contains several key constants that may need adjustment fo
|
||||
Defines the maximum upload size (default is `5G`). Ensure that PHP’s `upload_max_filesize` and `post_max_size` in your `php.ini` are consistent with this setting. The startup script (`start.sh`) updates PHP limits at runtime based on this value.
|
||||
|
||||
- **Environment Variables (Docker):**
|
||||
The Docker image supports overriding configuration via environment variables. For example, you can set `SECURE`, `SHARE_URL`, and port settings via the container’s environment.
|
||||
The Docker image supports overriding configuration via environment variables. For example, you can set `SECURE`, `SHARE_URL`, `PERSISTENT_TOKENS_KEY` and port settings via the container’s environment.
|
||||
|
||||
---
|
||||
|
||||
|
||||
20
addUser.php
20
addUser.php
@@ -6,9 +6,9 @@ $usersFile = USERS_DIR . USERS_FILE;
|
||||
|
||||
// Determine if we are in setup mode:
|
||||
// - Query parameter setup=1 is passed
|
||||
// - And users.txt is either missing or empty
|
||||
$isSetup = (isset($_GET['setup']) && $_GET['setup'] == '1');
|
||||
if ($isSetup && (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '')) {
|
||||
// - And users.txt is either missing or empty (zero bytes or trimmed content is empty)
|
||||
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
|
||||
if ($isSetup && (!file_exists($usersFile) || filesize($usersFile) == 0 || trim(file_get_contents($usersFile)) === '')) {
|
||||
// Allow initial admin creation without session checks.
|
||||
$setupMode = true;
|
||||
} else {
|
||||
@@ -16,7 +16,7 @@ if ($isSetup && (!file_exists($usersFile) || trim(file_get_contents($usersFile))
|
||||
// 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']) {
|
||||
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
http_response_code(403);
|
||||
exit;
|
||||
@@ -30,7 +30,7 @@ if ($isSetup && (!file_exists($usersFile) || trim(file_get_contents($usersFile))
|
||||
}
|
||||
}
|
||||
|
||||
// Get input data from JSON
|
||||
// Get input data from JSON.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
$newUsername = trim($data["username"] ?? "");
|
||||
$newPassword = trim($data["password"] ?? "");
|
||||
@@ -42,7 +42,7 @@ if ($setupMode) {
|
||||
$isAdmin = !empty($data["isAdmin"]) ? "1" : "0"; // "1" for admin, "0" for regular user.
|
||||
}
|
||||
|
||||
// Validate input
|
||||
// Validate input.
|
||||
if (!$newUsername || !$newPassword) {
|
||||
echo json_encode(["error" => "Username and password required"]);
|
||||
exit;
|
||||
@@ -54,12 +54,12 @@ if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $newUsername)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure users.txt exists
|
||||
// Ensure users.txt exists.
|
||||
if (!file_exists($usersFile)) {
|
||||
file_put_contents($usersFile, '');
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
// Check if username already exists.
|
||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($existingUsers as $line) {
|
||||
list($storedUser, $storedHash, $storedRole) = explode(':', trim($line));
|
||||
@@ -69,10 +69,10 @@ foreach ($existingUsers as $line) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
// Hash the password.
|
||||
$hashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
|
||||
|
||||
// Prepare new user line
|
||||
// Prepare new user line.
|
||||
$newUserLine = $newUsername . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
|
||||
|
||||
// In setup mode, overwrite users.txt; otherwise, append to it.
|
||||
|
||||
291
auth.js
291
auth.js
@@ -1,91 +1,127 @@
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
import { toggleVisibility, showToast } from './domUtils.js';
|
||||
import { toggleVisibility, showToast, attachEnterKeyListener, showCustomConfirmModal } from './domUtils.js';
|
||||
import { loadFileList, renderFileTable, displayFilePreview, initFileActions } from './fileManager.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
|
||||
function initAuth() {
|
||||
// First, check if the user is already authenticated.
|
||||
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 = '<i class="material-icons" title="Restore/Delete Trash">restore_from_trash</i>';
|
||||
/**
|
||||
* Updates the select element to reflect the stored items-per-page value.
|
||||
*/
|
||||
function updateItemsPerPageSelect() {
|
||||
const selectElem = document.querySelector(".form-control.bottom-select");
|
||||
if (selectElem) {
|
||||
const stored = localStorage.getItem("itemsPerPage") || "10";
|
||||
selectElem.value = stored;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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";
|
||||
/**
|
||||
* Updates the UI for an authenticated user.
|
||||
* This includes showing the main UI panels, attaching key listeners, updating header buttons,
|
||||
* and displaying admin-only buttons if applicable.
|
||||
*/
|
||||
function updateAuthenticatedUI(data) {
|
||||
toggleVisibility("loginForm", false);
|
||||
toggleVisibility("mainOperations", true);
|
||||
toggleVisibility("uploadFileForm", true);
|
||||
toggleVisibility("fileListContainer", true);
|
||||
attachEnterKeyListener("addUserModal", "saveUserBtn");
|
||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
|
||||
document.querySelector(".header-buttons").style.visibility = "visible";
|
||||
|
||||
// If admin, show admin-only buttons; otherwise hide them.
|
||||
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";
|
||||
let restoreBtn = document.getElementById("restoreFilesBtn");
|
||||
if (!restoreBtn) {
|
||||
restoreBtn = document.createElement("button");
|
||||
restoreBtn.id = "restoreFilesBtn";
|
||||
restoreBtn.classList.add("btn", "btn-warning");
|
||||
// Using a material icon for restore.
|
||||
restoreBtn.innerHTML = '<i class="material-icons" title="Restore/Delete Trash">restore_from_trash</i>';
|
||||
const headerButtons = document.querySelector(".header-buttons");
|
||||
if (headerButtons) {
|
||||
if (headerButtons.children.length >= 5) {
|
||||
headerButtons.insertBefore(restoreBtn, headerButtons.children[5]);
|
||||
} else {
|
||||
headerButtons.appendChild(restoreBtn);
|
||||
}
|
||||
}
|
||||
// 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 => {
|
||||
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";
|
||||
const restoreBtn = document.getElementById("restoreFilesBtn");
|
||||
if (restoreBtn) restoreBtn.style.display = "none";
|
||||
}
|
||||
updateItemsPerPageSelect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the user's authentication state and updates the UI accordingly.
|
||||
* If in setup mode or not authenticated, it shows the proper UI elements.
|
||||
* When authenticated, it calls updateAuthenticatedUI to handle the UI updates.
|
||||
*/
|
||||
function checkAuthentication(showLoginToast = true) {
|
||||
return sendRequest("checkAuth.php")
|
||||
.then(data => {
|
||||
if (data.setup) {
|
||||
window.setupMode = true;
|
||||
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);
|
||||
document.getElementById('newUsername').focus();
|
||||
return false;
|
||||
}
|
||||
window.setupMode = false;
|
||||
if (data.authenticated) {
|
||||
updateAuthenticatedUI(data);
|
||||
return data;
|
||||
} else {
|
||||
if (showLoginToast) showToast("Please log in to continue.");
|
||||
toggleVisibility("loginForm", true);
|
||||
toggleVisibility("mainOperations", false);
|
||||
toggleVisibility("uploadFileForm", false);
|
||||
toggleVisibility("fileListContainer", false);
|
||||
document.querySelector(".header-buttons").style.visibility = "hidden";
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error checking authentication:", error);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes authentication by checking the user's state and setting up event listeners.
|
||||
* The UI will update automatically based on the auth state.
|
||||
*/
|
||||
function initAuth() {
|
||||
checkAuthentication(false).catch(error => {
|
||||
console.error("Error checking authentication:", error);
|
||||
});
|
||||
|
||||
// Attach login event listener once.
|
||||
// Attach login event listener.
|
||||
const authForm = document.getElementById("authForm");
|
||||
if (authForm) {
|
||||
authForm.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
const rememberMe = document.getElementById("rememberMeCheckbox")
|
||||
? document.getElementById("rememberMeCheckbox").checked
|
||||
: false;
|
||||
const formData = {
|
||||
username: document.getElementById("loginUsername").value.trim(),
|
||||
password: document.getElementById("loginPassword").value.trim()
|
||||
password: document.getElementById("loginPassword").value.trim(),
|
||||
remember_me: rememberMe
|
||||
};
|
||||
sendRequest("auth.php", "POST", formData, { "X-CSRF-Token": window.csrfToken })
|
||||
.then(data => {
|
||||
@@ -94,7 +130,19 @@ function initAuth() {
|
||||
sessionStorage.setItem("welcomeMessage", "Welcome back, " + formData.username + "!");
|
||||
window.location.reload();
|
||||
} else {
|
||||
showToast("Login failed: " + (data.error || "Unknown error"));
|
||||
if (data.error && data.error.includes("Too many failed login attempts")) {
|
||||
showToast(data.error);
|
||||
const loginButton = authForm.querySelector("button[type='submit']");
|
||||
if (loginButton) {
|
||||
loginButton.disabled = true;
|
||||
setTimeout(() => {
|
||||
loginButton.disabled = false;
|
||||
showToast("You can now try logging in again.");
|
||||
}, 30 * 60 * 1000);
|
||||
}
|
||||
} else {
|
||||
showToast("Login failed: " + (data.error || "Unknown error"));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("❌ Error logging in:", error));
|
||||
@@ -116,10 +164,11 @@ function initAuth() {
|
||||
document.getElementById("addUserBtn").addEventListener("click", function () {
|
||||
resetUserForm();
|
||||
toggleVisibility("addUserModal", true);
|
||||
document.getElementById('newUsername').focus();
|
||||
});
|
||||
document.getElementById("saveUserBtn").addEventListener("click", function () {
|
||||
const newUsername = document.getElementById("newUsername").value.trim();
|
||||
const newPassword = document.getElementById("newPassword").value.trim();
|
||||
const newPassword = document.getElementById("addUserPassword").value.trim();
|
||||
const isAdmin = document.getElementById("isAdmin").checked;
|
||||
if (!newUsername || !newPassword) {
|
||||
showToast("Username and password are required!");
|
||||
@@ -143,7 +192,8 @@ function initAuth() {
|
||||
if (data.success) {
|
||||
showToast("User added successfully!");
|
||||
closeAddUserModal();
|
||||
checkAuthentication(false); // Re-check without showing toast
|
||||
// Re-check auth state to update the UI after adding a user.
|
||||
checkAuthentication(false);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not add user"));
|
||||
}
|
||||
@@ -159,14 +209,16 @@ function initAuth() {
|
||||
loadUserList();
|
||||
toggleVisibility("removeUserModal", true);
|
||||
});
|
||||
document.getElementById("deleteUserBtn").addEventListener("click", function () {
|
||||
|
||||
document.getElementById("deleteUserBtn").addEventListener("click", async function () {
|
||||
const selectElem = document.getElementById("removeUsernameSelect");
|
||||
const usernameToRemove = selectElem.value;
|
||||
if (!usernameToRemove) {
|
||||
showToast("Please select a user to remove.");
|
||||
return;
|
||||
}
|
||||
if (!confirm("Are you sure you want to delete user " + usernameToRemove + "?")) {
|
||||
const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?");
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
fetch("removeUser.php", {
|
||||
@@ -190,43 +242,60 @@ function initAuth() {
|
||||
})
|
||||
.catch(error => console.error("Error removing user:", error));
|
||||
});
|
||||
|
||||
document.getElementById("cancelRemoveUserBtn").addEventListener("click", function () {
|
||||
closeRemoveUserModal();
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
window.setupMode = false;
|
||||
if (data.authenticated) {
|
||||
return data;
|
||||
} else {
|
||||
if (showLoginToast) showToast("Please log in to continue.");
|
||||
toggleVisibility("loginForm", true);
|
||||
toggleVisibility("mainOperations", false);
|
||||
toggleVisibility("uploadFileForm", false);
|
||||
toggleVisibility("fileListContainer", false);
|
||||
document.querySelector(".header-buttons").style.visibility = "hidden";
|
||||
return false;
|
||||
}
|
||||
document.getElementById("changePasswordBtn").addEventListener("click", function () {
|
||||
document.getElementById("changePasswordModal").style.display = "block";
|
||||
document.getElementById("oldPassword").focus();
|
||||
});
|
||||
|
||||
document.getElementById("closeChangePasswordModal").addEventListener("click", function () {
|
||||
document.getElementById("changePasswordModal").style.display = "none";
|
||||
});
|
||||
|
||||
document.getElementById("saveNewPasswordBtn").addEventListener("click", function () {
|
||||
const oldPassword = document.getElementById("oldPassword").value.trim();
|
||||
const newPassword = document.getElementById("newPassword").value.trim();
|
||||
const confirmPassword = document.getElementById("confirmPassword").value.trim();
|
||||
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||
showToast("Please fill in all fields.");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
showToast("New passwords do not match.");
|
||||
return;
|
||||
}
|
||||
const data = { oldPassword, newPassword, confirmPassword };
|
||||
fetch("changePassword.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error checking authentication:", error);
|
||||
return false;
|
||||
});
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast(result.success);
|
||||
document.getElementById("oldPassword").value = "";
|
||||
document.getElementById("newPassword").value = "";
|
||||
document.getElementById("confirmPassword").value = "";
|
||||
document.getElementById("changePasswordModal").style.display = "none";
|
||||
} else {
|
||||
showToast("Error: " + (result.error || "Could not change password."));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error changing password:", error);
|
||||
showToast("Error changing password.");
|
||||
});
|
||||
});
|
||||
}
|
||||
window.checkAuthentication = checkAuthentication;
|
||||
|
||||
window.changeItemsPerPage = function (value) {
|
||||
localStorage.setItem("itemsPerPage", value);
|
||||
@@ -237,16 +306,12 @@ window.changeItemsPerPage = function (value) {
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const selectElem = document.querySelector(".form-control.bottom-select");
|
||||
if (selectElem) {
|
||||
const stored = localStorage.getItem("itemsPerPage") || "10";
|
||||
selectElem.value = stored;
|
||||
}
|
||||
updateItemsPerPageSelect();
|
||||
});
|
||||
|
||||
function resetUserForm() {
|
||||
document.getElementById("newUsername").value = "";
|
||||
document.getElementById("newPassword").value = "";
|
||||
document.getElementById("addUserPassword").value = "";
|
||||
}
|
||||
|
||||
function closeAddUserModal() {
|
||||
|
||||
101
auth.php
101
auth.php
@@ -4,15 +4,54 @@ header('Content-Type: application/json');
|
||||
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
|
||||
// Function to authenticate user
|
||||
// --- Brute Force Protection Settings ---
|
||||
$maxAttempts = 5;
|
||||
$lockoutTime = 30 * 60; // 30 minutes in seconds
|
||||
$attemptsFile = USERS_DIR . 'failed_logins.json'; // JSON file for tracking failed login attempts
|
||||
$failedLogFile = USERS_DIR . 'failed_login.log'; // Plain text log for fail2ban
|
||||
|
||||
// Persistent tokens file for "Remember me"
|
||||
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
|
||||
|
||||
// Load failed attempts data from file.
|
||||
function loadFailedAttempts($file) {
|
||||
if (file_exists($file)) {
|
||||
$data = json_decode(file_get_contents($file), true);
|
||||
if (is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Save failed attempts data to file.
|
||||
function saveFailedAttempts($file, $data) {
|
||||
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
// Get current IP address.
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
$currentTime = time();
|
||||
|
||||
// Load failed attempts.
|
||||
$failedAttempts = loadFailedAttempts($attemptsFile);
|
||||
|
||||
// Check if this IP is currently locked out.
|
||||
if (isset($failedAttempts[$ip])) {
|
||||
$attemptData = $failedAttempts[$ip];
|
||||
if ($attemptData['count'] >= $maxAttempts && ($currentTime - $attemptData['last_attempt']) < $lockoutTime) {
|
||||
echo json_encode(["error" => "Too many failed login attempts. Please try again later."]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Authentication Function ---
|
||||
function authenticate($username, $password)
|
||||
{
|
||||
global $usersFile;
|
||||
|
||||
if (!file_exists($usersFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
list($storedUser, $storedPass, $storedRole) = explode(':', trim($line), 3);
|
||||
@@ -23,10 +62,11 @@ function authenticate($username, $password)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get JSON input
|
||||
// Get JSON input.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
$username = trim($data["username"] ?? "");
|
||||
$password = trim($data["password"] ?? "");
|
||||
$rememberMe = isset($data["remember_me"]) && $data["remember_me"] === true;
|
||||
|
||||
// Validate input: ensure both fields are provided.
|
||||
if (!$username || !$password) {
|
||||
@@ -34,22 +74,69 @@ if (!$username || !$password) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate username format: allow only letters, numbers, underscores, dashes, and spaces.
|
||||
// Validate username format.
|
||||
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $username)) {
|
||||
echo json_encode(["error" => "Invalid username format. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
// Attempt to authenticate the user.
|
||||
$userRole = authenticate($username, $password);
|
||||
if ($userRole !== false) {
|
||||
// Regenerate session ID to mitigate session fixation attacks
|
||||
// On successful login, reset failed attempts for this IP.
|
||||
if (isset($failedAttempts[$ip])) {
|
||||
unset($failedAttempts[$ip]);
|
||||
saveFailedAttempts($attemptsFile, $failedAttempts);
|
||||
}
|
||||
// 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
|
||||
|
||||
// If "Remember me" is checked, generate a persistent login token.
|
||||
if ($rememberMe) {
|
||||
// Generate a secure random token.
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expiry = time() + (30 * 24 * 60 * 60); // 30 days
|
||||
|
||||
// Load existing persistent tokens.
|
||||
$persistentTokens = [];
|
||||
if (file_exists($persistentTokensFile)) {
|
||||
$encryptedContent = file_get_contents($persistentTokensFile);
|
||||
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
||||
$persistentTokens = json_decode($decryptedContent, true);
|
||||
if (!is_array($persistentTokens)) {
|
||||
$persistentTokens = [];
|
||||
}
|
||||
}
|
||||
// Save token along with username, expiry, and admin status.
|
||||
$persistentTokens[$token] = [
|
||||
"username" => $username,
|
||||
"expiry" => $expiry,
|
||||
"isAdmin" => ($userRole === "1")
|
||||
];
|
||||
$encryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
|
||||
file_put_contents($persistentTokensFile, $encryptedContent, LOCK_EX);
|
||||
// Set the cookie. (Assuming $secure is defined in config.php.)
|
||||
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
||||
}
|
||||
|
||||
echo json_encode(["success" => "Login successful", "isAdmin" => $_SESSION["isAdmin"]]);
|
||||
} else {
|
||||
// On failed login, update failed attempts.
|
||||
if (isset($failedAttempts[$ip])) {
|
||||
$failedAttempts[$ip]['count']++;
|
||||
$failedAttempts[$ip]['last_attempt'] = $currentTime;
|
||||
} else {
|
||||
$failedAttempts[$ip] = ['count' => 1, 'last_attempt' => $currentTime];
|
||||
}
|
||||
saveFailedAttempts($attemptsFile, $failedAttempts);
|
||||
|
||||
// Log the failed attempt to the plain text log for fail2ban.
|
||||
$logLine = date('Y-m-d H:i:s') . " - Failed login attempt for username: " . $username . " from IP: " . $ip . PHP_EOL;
|
||||
file_put_contents($failedLogFile, $logLine, FILE_APPEND);
|
||||
|
||||
echo json_encode(["error" => "Invalid credentials"]);
|
||||
}
|
||||
?>
|
||||
85
changePassword.php
Normal file
85
changePassword.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
// changePassword.php
|
||||
require 'config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Make sure the user is logged in.
|
||||
session_start();
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if (!$username) {
|
||||
echo json_encode(["error" => "No username in session"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF token check.
|
||||
$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;
|
||||
}
|
||||
|
||||
// Get POST data.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
$oldPassword = trim($data["oldPassword"] ?? "");
|
||||
$newPassword = trim($data["newPassword"] ?? "");
|
||||
$confirmPassword = trim($data["confirmPassword"] ?? "");
|
||||
|
||||
// Validate input.
|
||||
if (!$oldPassword || !$newPassword || !$confirmPassword) {
|
||||
echo json_encode(["error" => "All fields are required."]);
|
||||
exit;
|
||||
}
|
||||
if ($newPassword !== $confirmPassword) {
|
||||
echo json_encode(["error" => "New passwords do not match."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Path to users file.
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!file_exists($usersFile)) {
|
||||
echo json_encode(["error" => "Users file not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Read current users.
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$userFound = false;
|
||||
$newLines = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
list($storedUser, $storedHash, $storedRole) = explode(':', trim($line));
|
||||
if ($storedUser === $username) {
|
||||
$userFound = true;
|
||||
// Verify the old password.
|
||||
if (!password_verify($oldPassword, $storedHash)) {
|
||||
echo json_encode(["error" => "Old password is incorrect."]);
|
||||
exit;
|
||||
}
|
||||
// Hash the new password.
|
||||
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
|
||||
// Rebuild the line with the new hash.
|
||||
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole;
|
||||
} else {
|
||||
$newLines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$userFound) {
|
||||
echo json_encode(["error" => "User not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Save updated users file.
|
||||
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) {
|
||||
echo json_encode(["success" => "Password updated successfully."]);
|
||||
} else {
|
||||
echo json_encode(["error" => "Could not update password."]);
|
||||
}
|
||||
?>
|
||||
92
config.php
92
config.php
@@ -1,6 +1,57 @@
|
||||
<?php
|
||||
// config.php
|
||||
|
||||
// Define constants first.
|
||||
define('UPLOAD_DIR', '/var/www/uploads/');
|
||||
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/');
|
||||
define('TIMEZONE', 'America/New_York');
|
||||
define('DATE_TIME_FORMAT', 'm/d/y h:iA');
|
||||
define('TOTAL_UPLOAD_SIZE', '5G');
|
||||
|
||||
// Set the default timezone.
|
||||
date_default_timezone_set(TIMEZONE);
|
||||
|
||||
/**
|
||||
* Encrypts data using AES-256-CBC.
|
||||
*
|
||||
* @param string $data The plaintext data.
|
||||
* @param string $encryptionKey The secret encryption key.
|
||||
* @return string Base64-encoded string containing IV and ciphertext.
|
||||
*/
|
||||
function encryptData($data, $encryptionKey) {
|
||||
$cipher = 'AES-256-CBC';
|
||||
$ivlen = openssl_cipher_iv_length($cipher);
|
||||
$iv = openssl_random_pseudo_bytes($ivlen);
|
||||
$ciphertext = openssl_encrypt($data, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
|
||||
return base64_encode($iv . $ciphertext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts data encrypted with AES-256-CBC.
|
||||
*
|
||||
* @param string $encryptedData The Base64-encoded data containing IV and ciphertext.
|
||||
* @param string $encryptionKey The secret encryption key.
|
||||
* @return string|false The decrypted plaintext or false on failure.
|
||||
*/
|
||||
function decryptData($encryptedData, $encryptionKey) {
|
||||
$cipher = 'AES-256-CBC';
|
||||
$data = base64_decode($encryptedData);
|
||||
$ivlen = openssl_cipher_iv_length($cipher);
|
||||
$iv = substr($data, 0, $ivlen);
|
||||
$ciphertext = substr($data, $ivlen);
|
||||
return openssl_decrypt($ciphertext, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
|
||||
}
|
||||
|
||||
// Load encryption key from an environment variable (default for testing; override in production)
|
||||
$encryptionKey = getenv('PERSISTENT_TOKENS_KEY') ?: 'default_please_change_this_key';
|
||||
if (!$encryptionKey) {
|
||||
die('Encryption key for persistent tokens is not set.');
|
||||
}
|
||||
|
||||
// Allow an environment variable to override HTTPS detection.
|
||||
$envSecure = getenv('SECURE');
|
||||
if ($envSecure !== false) {
|
||||
@@ -23,10 +74,40 @@ session_set_cookie_params($cookieParams);
|
||||
ini_set('session.gc_maxlifetime', 7200);
|
||||
session_start();
|
||||
|
||||
// Generate CSRF token if not already set.
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
// Auto-login via persistent token if session is not active.
|
||||
if (!isset($_SESSION["authenticated"]) && isset($_COOKIE['remember_me_token'])) {
|
||||
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
|
||||
$persistentTokens = [];
|
||||
if (file_exists($persistentTokensFile)) {
|
||||
$encryptedContent = file_get_contents($persistentTokensFile);
|
||||
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
||||
$persistentTokens = json_decode($decryptedContent, true);
|
||||
if (!is_array($persistentTokens)) {
|
||||
$persistentTokens = [];
|
||||
}
|
||||
}
|
||||
if (is_array($persistentTokens) && isset($persistentTokens[$_COOKIE['remember_me_token']])) {
|
||||
$tokenData = $persistentTokens[$_COOKIE['remember_me_token']];
|
||||
if ($tokenData['expiry'] >= time()) {
|
||||
// Token is valid; auto-authenticate the user.
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $tokenData["username"];
|
||||
$_SESSION["isAdmin"] = $tokenData["isAdmin"]; // Restore admin status from the token
|
||||
} else {
|
||||
// Token expired; remove it and clear the cookie.
|
||||
unset($persistentTokens[$_COOKIE['remember_me_token']]);
|
||||
$newEncryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
|
||||
file_put_contents($persistentTokensFile, $newEncryptedContent, LOCK_EX);
|
||||
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Define BASE_URL (this should point to where index.html is, e.g. your uploads directory)
|
||||
define('BASE_URL', 'http://yourwebsite/uploads/');
|
||||
|
||||
@@ -41,15 +122,4 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
||||
}
|
||||
|
||||
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');
|
||||
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);
|
||||
?>
|
||||
@@ -20,10 +20,8 @@ if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||
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.
|
||||
$token = bin2hex(random_bytes(16)); // 32 hex characters.
|
||||
|
||||
// Calculate expiration (Unix timestamp).
|
||||
$expires = time() + ($expirationMinutes * 60);
|
||||
@@ -42,6 +40,14 @@ if (file_exists($shareFile)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired share links.
|
||||
$currentTime = time();
|
||||
foreach ($shareLinks as $key => $link) {
|
||||
if ($link["expires"] < $currentTime) {
|
||||
unset($shareLinks[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add record.
|
||||
$shareLinks[$token] = [
|
||||
"folder" => $folder,
|
||||
|
||||
75
domUtils.js
75
domUtils.js
@@ -28,35 +28,39 @@ export function toggleAllCheckboxes(masterCheckbox) {
|
||||
}
|
||||
|
||||
export function updateFileActionButtons() {
|
||||
const fileListContainer = document.getElementById("fileList");
|
||||
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
|
||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||
const copyBtn = document.getElementById("copySelectedBtn");
|
||||
const moveBtn = document.getElementById("moveSelectedBtn");
|
||||
const deleteBtn = document.getElementById("deleteSelectedBtn");
|
||||
const zipBtn = document.getElementById("downloadZipBtn");
|
||||
const extractZipBtn = document.getElementById("extractZipBtn");
|
||||
|
||||
if (fileCheckboxes.length === 0) {
|
||||
if (copyBtn) copyBtn.style.display = "none";
|
||||
if (moveBtn) moveBtn.style.display = "none";
|
||||
if (deleteBtn) deleteBtn.style.display = "none";
|
||||
if (zipBtn) zipBtn.style.display = "none";
|
||||
if (extractZipBtn) extractZipBtn.style.display = "none";
|
||||
} else {
|
||||
if (copyBtn) copyBtn.style.display = "inline-block";
|
||||
if (moveBtn) moveBtn.style.display = "inline-block";
|
||||
if (deleteBtn) deleteBtn.style.display = "inline-block";
|
||||
if (zipBtn) zipBtn.style.display = "inline-block";
|
||||
if (extractZipBtn) extractZipBtn.style.display = "inline-block";
|
||||
|
||||
if (selectedCheckboxes.length > 0) {
|
||||
if (copyBtn) copyBtn.disabled = false;
|
||||
if (moveBtn) moveBtn.disabled = false;
|
||||
if (deleteBtn) deleteBtn.disabled = false;
|
||||
if (zipBtn) zipBtn.disabled = false;
|
||||
} else {
|
||||
if (copyBtn) copyBtn.disabled = true;
|
||||
if (moveBtn) moveBtn.disabled = true;
|
||||
if (deleteBtn) deleteBtn.disabled = true;
|
||||
if (zipBtn) zipBtn.disabled = true;
|
||||
const anySelected = selectedCheckboxes.length > 0;
|
||||
if (copyBtn) copyBtn.disabled = !anySelected;
|
||||
if (moveBtn) moveBtn.disabled = !anySelected;
|
||||
if (deleteBtn) deleteBtn.disabled = !anySelected;
|
||||
if (zipBtn) zipBtn.disabled = !anySelected;
|
||||
|
||||
if (extractZipBtn) {
|
||||
// Enable only if at least one selected file ends with .zip (case-insensitive).
|
||||
const anyZipSelected = Array.from(selectedCheckboxes).some(chk =>
|
||||
chk.value.toLowerCase().endsWith(".zip")
|
||||
);
|
||||
extractZipBtn.disabled = !anyZipSelected;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,4 +309,53 @@ export function previewFile(fileUrl, fileName) {
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
export function attachEnterKeyListener(modalId, buttonId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
// Make the modal focusable
|
||||
modal.setAttribute("tabindex", "-1");
|
||||
modal.focus();
|
||||
modal.addEventListener("keydown", function(e) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById(buttonId);
|
||||
if (btn) {
|
||||
btn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function showCustomConfirmModal(message) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById("customConfirmModal");
|
||||
const messageElem = document.getElementById("confirmMessage");
|
||||
const yesBtn = document.getElementById("confirmYesBtn");
|
||||
const noBtn = document.getElementById("confirmNoBtn");
|
||||
|
||||
messageElem.textContent = message;
|
||||
modal.style.display = "block";
|
||||
|
||||
// Cleanup function to hide the modal and remove event listeners.
|
||||
function cleanup() {
|
||||
modal.style.display = "none";
|
||||
yesBtn.removeEventListener("click", onYes);
|
||||
noBtn.removeEventListener("click", onNo);
|
||||
}
|
||||
|
||||
function onYes() {
|
||||
cleanup();
|
||||
resolve(true);
|
||||
}
|
||||
function onNo() {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}
|
||||
|
||||
yesBtn.addEventListener("click", onYes);
|
||||
noBtn.addEventListener("click", onNo);
|
||||
});
|
||||
}
|
||||
146
extractZip.php
Normal file
146
extractZip.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
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']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ensure user is authenticated.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Read and decode the JSON input.
|
||||
$rawData = file_get_contents("php://input");
|
||||
$data = json_decode($rawData, true);
|
||||
if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid input."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$folder = $data['folder'];
|
||||
$files = $data['files'];
|
||||
|
||||
if (empty($files)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "No files specified."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate folder name (allow "root" or valid subfolder names).
|
||||
if ($folder !== "root") {
|
||||
$parts = explode('/', $folder);
|
||||
foreach ($parts as $part) {
|
||||
if (empty($part) || $part === '.' || $part === '..' || !preg_match('/^[A-Za-z0-9_\-\.\(\) ]+$/', $part)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$relativePath = implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR;
|
||||
} else {
|
||||
$relativePath = "";
|
||||
}
|
||||
|
||||
$baseDir = realpath(UPLOAD_DIR);
|
||||
if ($baseDir === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Uploads directory not configured correctly."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath;
|
||||
$folderPathReal = realpath($folderPath);
|
||||
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Folder not found."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---------- Metadata Setup ----------
|
||||
function getMetadataFilePath($folder) {
|
||||
if (strtolower($folder) === 'root' || $folder === '') {
|
||||
return META_DIR . "root_metadata.json";
|
||||
}
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
|
||||
}
|
||||
|
||||
$srcMetaFile = getMetadataFilePath($folder);
|
||||
$destMetaFile = getMetadataFilePath($folder);
|
||||
$srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : [];
|
||||
$destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : [];
|
||||
|
||||
$errors = [];
|
||||
$safeFileNamePattern = '/^[A-Za-z0-9_\-\.\(\) ]+$/';
|
||||
$allSuccess = true;
|
||||
|
||||
// ---------- Process Each File ----------
|
||||
foreach ($files as $zipFileName) {
|
||||
$originalName = basename(trim($zipFileName));
|
||||
// Process only .zip files.
|
||||
if (strtolower(substr($originalName, -4)) !== '.zip') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match($safeFileNamePattern, $originalName)) {
|
||||
$errors[] = "$originalName has an invalid name.";
|
||||
$allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
$zipFilePath = $folderPathReal . DIRECTORY_SEPARATOR . $originalName;
|
||||
if (!file_exists($zipFilePath)) {
|
||||
$errors[] = "$originalName does not exist in folder.";
|
||||
$allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFilePath) !== TRUE) {
|
||||
$errors[] = "Could not open $originalName as a zip file.";
|
||||
$allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Attempt extraction.
|
||||
if (!$zip->extractTo($folderPathReal)) {
|
||||
$errors[] = "Failed to extract $originalName.";
|
||||
$allSuccess = false;
|
||||
} else {
|
||||
// Update metadata for each extracted file if the zip file has metadata.
|
||||
if (isset($srcMetadata[$originalName])) {
|
||||
$zipMeta = $srcMetadata[$originalName];
|
||||
// Iterate through all entries in the zip.
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$entryName = $zip->getNameIndex($i);
|
||||
$extractedFileName = basename($entryName);
|
||||
if ($extractedFileName) {
|
||||
$destMetadata[$extractedFileName] = $zipMeta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
// Write updated metadata back to the destination metadata file.
|
||||
if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) {
|
||||
$errors[] = "Failed to update metadata.";
|
||||
$allSuccess = false;
|
||||
}
|
||||
|
||||
if ($allSuccess) {
|
||||
echo json_encode(["success" => true]);
|
||||
} else {
|
||||
echo json_encode(["success" => false, "error" => implode(" ", $errors)]);
|
||||
}
|
||||
exit;
|
||||
?>
|
||||
266
fileManager.js
266
fileManager.js
@@ -9,6 +9,7 @@ import {
|
||||
showToast,
|
||||
updateRowHighlight,
|
||||
toggleRowSelection,
|
||||
attachEnterKeyListener,
|
||||
previewFile as originalPreviewFile
|
||||
} from './domUtils.js';
|
||||
|
||||
@@ -172,7 +173,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;">×</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);
|
||||
@@ -661,6 +662,7 @@ export function handleDeleteSelected(e) {
|
||||
document.getElementById("deleteFilesMessage").textContent =
|
||||
"Are you sure you want to delete " + window.filesToDelete.length + " selected file(s)?";
|
||||
document.getElementById("deleteFilesModal").style.display = "block";
|
||||
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
@@ -671,6 +673,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
window.filesToDelete = [];
|
||||
});
|
||||
}
|
||||
|
||||
const confirmDelete = document.getElementById("confirmDeleteFiles");
|
||||
if (confirmDelete) {
|
||||
confirmDelete.addEventListener("click", function () {
|
||||
@@ -700,7 +703,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
attachEnterKeyListener("downloadZipModal", "confirmDownloadZip");
|
||||
export function handleDownloadZipSelected(e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
@@ -711,6 +714,64 @@ export function handleDownloadZipSelected(e) {
|
||||
}
|
||||
window.filesToDownload = Array.from(checkboxes).map(chk => chk.value);
|
||||
document.getElementById("downloadZipModal").style.display = "block";
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById("zipFileNameInput");
|
||||
input.focus();
|
||||
}, 100);
|
||||
|
||||
}
|
||||
|
||||
export function handleExtractZipSelected(e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
// Get selected file names
|
||||
const checkboxes = document.querySelectorAll(".file-checkbox:checked");
|
||||
if (!checkboxes.length) {
|
||||
showToast("No files selected.");
|
||||
return;
|
||||
}
|
||||
// Filter for zip files only
|
||||
const zipFiles = Array.from(checkboxes)
|
||||
.map(chk => chk.value)
|
||||
.filter(name => name.toLowerCase().endsWith(".zip"));
|
||||
if (!zipFiles.length) {
|
||||
showToast("No zip files selected.");
|
||||
return;
|
||||
}
|
||||
// Call the extract endpoint with the selected zip files
|
||||
fetch("extractZip.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder: window.currentFolder || "root",
|
||||
files: zipFiles
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast("Zip file(s) extracted successfully!");
|
||||
loadFileList(window.currentFolder);
|
||||
} else {
|
||||
showToast("Error extracting zip: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error extracting zip files:", error);
|
||||
showToast("Error extracting zip files.");
|
||||
});
|
||||
}
|
||||
|
||||
const extractZipBtn = document.getElementById("extractZipBtn");
|
||||
if (extractZipBtn) {
|
||||
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
|
||||
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
@@ -720,6 +781,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
document.getElementById("downloadZipModal").style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
const confirmDownloadZip = document.getElementById("confirmDownloadZip");
|
||||
if (confirmDownloadZip) {
|
||||
confirmDownloadZip.addEventListener("click", function () {
|
||||
@@ -1007,7 +1069,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");
|
||||
}
|
||||
@@ -1035,7 +1097,7 @@ export function editFile(fileName, folder) {
|
||||
fetch(fileUrl, { method: "HEAD" })
|
||||
.then(response => {
|
||||
const contentLength = response.headers.get("Content-Length");
|
||||
if (!contentLength || parseInt(contentLength) > 10485760) {
|
||||
if (contentLength !== null && parseInt(contentLength) > 10485760) {
|
||||
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
||||
throw new Error("File too large.");
|
||||
}
|
||||
@@ -1054,12 +1116,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">×</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 +1219,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";
|
||||
@@ -1192,13 +1258,28 @@ export function initFileActions() {
|
||||
downloadZipBtn.replaceWith(downloadZipBtn.cloneNode(true));
|
||||
document.getElementById("downloadZipBtn").addEventListener("click", handleDownloadZipSelected);
|
||||
}
|
||||
const extractZipBtn = document.getElementById("extractZipBtn");
|
||||
if (extractZipBtn) {
|
||||
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
|
||||
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
|
||||
}
|
||||
}
|
||||
|
||||
attachEnterKeyListener("renameFileModal", "submitRenameFile");
|
||||
export function renameFile(oldName, folder) {
|
||||
window.fileToRename = oldName;
|
||||
window.fileFolder = folder || window.currentFolder || "root";
|
||||
document.getElementById("newFileName").value = oldName;
|
||||
document.getElementById("renameFileModal").style.display = "block";
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById("newFileName");
|
||||
input.focus();
|
||||
const lastDot = oldName.lastIndexOf('.');
|
||||
if (lastDot > 0) {
|
||||
input.setSelectionRange(0, lastDot);
|
||||
} else {
|
||||
input.select();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
@@ -1209,6 +1290,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("newFileName").value = "";
|
||||
});
|
||||
}
|
||||
|
||||
const submitBtn = document.getElementById("submitRenameFile");
|
||||
if (submitBtn) {
|
||||
submitBtn.addEventListener("click", function () {
|
||||
@@ -1269,4 +1351,164 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||
el.addEventListener("drop", folderDropHandler);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function(e) {
|
||||
// Skip if focus is on an input, textarea, or any contentEditable element.
|
||||
const tag = e.target.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
// On Mac, the delete key is often reported as "Backspace" (keyCode 8)
|
||||
if (e.key === "Delete" || e.key === "Backspace" || e.keyCode === 46 || e.keyCode === 8) {
|
||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||
if (selectedCheckboxes.length > 0) {
|
||||
e.preventDefault(); // Prevent default back navigation in some browsers.
|
||||
handleDeleteSelected(new Event("click"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- CONTEXT MENU SUPPORT FOR FILE LIST ----------
|
||||
|
||||
// Function to display the context menu with provided items at (x, y)
|
||||
function showFileContextMenu(x, y, menuItems) {
|
||||
let menu = document.getElementById("fileContextMenu");
|
||||
if (!menu) {
|
||||
menu = document.createElement("div");
|
||||
menu.id = "fileContextMenu";
|
||||
menu.style.position = "absolute";
|
||||
menu.style.backgroundColor = "#fff";
|
||||
menu.style.border = "1px solid #ccc";
|
||||
menu.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.2)";
|
||||
menu.style.zIndex = "9999";
|
||||
menu.style.padding = "5px 0";
|
||||
menu.style.minWidth = "150px";
|
||||
document.body.appendChild(menu);
|
||||
}
|
||||
// Clear previous items
|
||||
menu.innerHTML = "";
|
||||
menuItems.forEach(item => {
|
||||
let menuItem = document.createElement("div");
|
||||
menuItem.textContent = item.label;
|
||||
menuItem.style.padding = "5px 15px";
|
||||
menuItem.style.cursor = "pointer";
|
||||
menuItem.addEventListener("mouseover", () => {
|
||||
if (document.body.classList.contains("dark-mode")) {
|
||||
menuItem.style.backgroundColor = "#444"; // darker gray for dark mode
|
||||
} else {
|
||||
menuItem.style.backgroundColor = "#f0f0f0"; // light gray for light mode
|
||||
}
|
||||
});
|
||||
menuItem.addEventListener("mouseout", () => {
|
||||
menuItem.style.backgroundColor = "";
|
||||
});
|
||||
menuItem.addEventListener("click", () => {
|
||||
item.action();
|
||||
hideFileContextMenu();
|
||||
});
|
||||
menu.appendChild(menuItem);
|
||||
});
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
menu.style.display = "block";
|
||||
}
|
||||
|
||||
function hideFileContextMenu() {
|
||||
const menu = document.getElementById("fileContextMenu");
|
||||
if (menu) {
|
||||
menu.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu handler for the file list.
|
||||
function fileListContextMenuHandler(e) {
|
||||
e.preventDefault();
|
||||
// If no file is selected, try to select the row that was right-clicked.
|
||||
let row = e.target.closest("tr");
|
||||
if (row) {
|
||||
const checkbox = row.querySelector(".file-checkbox");
|
||||
if (checkbox && !checkbox.checked) {
|
||||
checkbox.checked = true;
|
||||
updateRowHighlight(checkbox);
|
||||
updateFileActionButtons();
|
||||
}
|
||||
}
|
||||
|
||||
// Get selected file names.
|
||||
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
|
||||
|
||||
// Build the context menu items.
|
||||
let menuItems = [
|
||||
{ label: "Delete Selected", action: () => { handleDeleteSelected(new Event("click")); } },
|
||||
{ label: "Copy Selected", action: () => { handleCopySelected(new Event("click")); } },
|
||||
{ label: "Move Selected", action: () => { handleMoveSelected(new Event("click")); } },
|
||||
{ label: "Download Zip", action: () => { handleDownloadZipSelected(new Event("click")); } }
|
||||
];
|
||||
|
||||
if (selected.some(name => name.toLowerCase().endsWith(".zip"))) {
|
||||
menuItems.push({
|
||||
label: "Extract Zip",
|
||||
action: () => { handleExtractZipSelected(new Event("click")); }
|
||||
});
|
||||
}
|
||||
|
||||
if (selected.length === 1) {
|
||||
// Look up the file object.
|
||||
const file = fileData.find(f => f.name === selected[0]);
|
||||
|
||||
// Add Preview option.
|
||||
menuItems.push({
|
||||
label: "Preview",
|
||||
action: () => {
|
||||
const folder = window.currentFolder || "root";
|
||||
const folderPath = folder === "root"
|
||||
? "uploads/"
|
||||
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
|
||||
previewFile(folderPath + encodeURIComponent(file.name) + "?t=" + new Date().getTime(), file.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Only show Edit option if file is editable.
|
||||
if (canEditFile(file.name)) {
|
||||
menuItems.push({
|
||||
label: "Edit",
|
||||
action: () => { editFile(selected[0], window.currentFolder); }
|
||||
});
|
||||
}
|
||||
|
||||
// Add Rename option.
|
||||
menuItems.push({
|
||||
label: "Rename",
|
||||
action: () => { renameFile(selected[0], window.currentFolder); }
|
||||
});
|
||||
}
|
||||
|
||||
showFileContextMenu(e.pageX, e.pageY, menuItems);
|
||||
}
|
||||
|
||||
// Bind the context menu to the file list container.
|
||||
// (This is set every time the file list is rendered.)
|
||||
function bindFileListContextMenu() {
|
||||
const fileListContainer = document.getElementById("fileList");
|
||||
if (fileListContainer) {
|
||||
fileListContainer.oncontextmenu = fileListContextMenuHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the context menu if clicking anywhere else.
|
||||
document.addEventListener("click", function(e) {
|
||||
const menu = document.getElementById("fileContextMenu");
|
||||
if (menu && menu.style.display === "block") {
|
||||
hideFileContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// After rendering the file table, bind the context menu handler.
|
||||
(function() {
|
||||
const originalRenderFileTable = renderFileTable;
|
||||
renderFileTable = function(folder) {
|
||||
originalRenderFileTable(folder);
|
||||
bindFileListContextMenu();
|
||||
};
|
||||
})();
|
||||
322
folderManager.js
322
folderManager.js
@@ -1,7 +1,7 @@
|
||||
// folderManager.js
|
||||
|
||||
import { loadFileList } from './fileManager.js';
|
||||
import { showToast, escapeHTML } from './domUtils.js';
|
||||
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
|
||||
|
||||
// ----------------------
|
||||
// Helper Functions (Data/State)
|
||||
@@ -60,7 +60,111 @@ function getParentFolder(folder) {
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
// DOM Building Functions
|
||||
// Breadcrumb Functions
|
||||
// ----------------------
|
||||
// Render breadcrumb for a normalized folder path.
|
||||
// For example, if window.currentFolder is "Folder1/Folder1SubFolder2",
|
||||
// this will return: Root / Folder1 / Folder1SubFolder2.
|
||||
function renderBreadcrumb(normalizedFolder) {
|
||||
if (normalizedFolder === "root") {
|
||||
return `<span class="breadcrumb-link" data-folder="root">Root</span>`;
|
||||
}
|
||||
const parts = normalizedFolder.split("/");
|
||||
let breadcrumbItems = [];
|
||||
// Always start with "Root".
|
||||
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="root">Root</span>`);
|
||||
let cumulative = "";
|
||||
parts.forEach((part, index) => {
|
||||
cumulative = index === 0 ? part : cumulative + "/" + part;
|
||||
breadcrumbItems.push(`<span class="breadcrumb-separator"> / </span>`);
|
||||
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${cumulative}">${escapeHTML(part)}</span>`);
|
||||
});
|
||||
return breadcrumbItems.join('');
|
||||
}
|
||||
|
||||
// Bind click and drag-and-drop events to breadcrumb links.
|
||||
function bindBreadcrumbEvents() {
|
||||
const breadcrumbLinks = document.querySelectorAll(".breadcrumb-link");
|
||||
breadcrumbLinks.forEach(link => {
|
||||
// Click event for navigation.
|
||||
link.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
let folder = this.getAttribute("data-folder");
|
||||
window.currentFolder = folder;
|
||||
localStorage.setItem("lastOpenedFolder", folder);
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
if (folder === "root") {
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb("root") + ")";
|
||||
} else {
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb(folder) + ")";
|
||||
}
|
||||
// Expand the folder tree to ensure the target is visible.
|
||||
expandTreePath(folder);
|
||||
// Update folder tree selection.
|
||||
document.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
||||
const targetOption = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
||||
if (targetOption) {
|
||||
targetOption.classList.add("selected");
|
||||
}
|
||||
// Load the file list.
|
||||
loadFileList(folder);
|
||||
// Re-bind breadcrumb events to ensure all links remain active.
|
||||
bindBreadcrumbEvents();
|
||||
});
|
||||
|
||||
// Drag-and-drop events.
|
||||
link.addEventListener("dragover", function (e) {
|
||||
e.preventDefault();
|
||||
this.classList.add("drop-hover");
|
||||
});
|
||||
link.addEventListener("dragleave", function (e) {
|
||||
this.classList.remove("drop-hover");
|
||||
});
|
||||
link.addEventListener("drop", function (e) {
|
||||
e.preventDefault();
|
||||
this.classList.remove("drop-hover");
|
||||
const dropFolder = this.getAttribute("data-folder");
|
||||
let dragData;
|
||||
try {
|
||||
dragData = JSON.parse(e.dataTransfer.getData("application/json"));
|
||||
} catch (err) {
|
||||
console.error("Invalid drag data on breadcrumb:", err);
|
||||
return;
|
||||
}
|
||||
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||
if (filesToMove.length === 0) return;
|
||||
fetch("moveFiles.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: dragData.sourceFolder,
|
||||
files: filesToMove,
|
||||
destination: dropFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
||||
loadFileList(dragData.sourceFolder);
|
||||
} else {
|
||||
showToast("Error moving files: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error moving files via drop on breadcrumb:", error);
|
||||
showToast("Error moving files.");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
// DOM Building Functions for Folder Tree
|
||||
// ----------------------
|
||||
|
||||
// Recursively builds HTML for the folder tree as nested <ul> elements.
|
||||
@@ -72,18 +176,16 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
||||
if (folder.toLowerCase() === "trash") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = parentPath ? parentPath + "/" + folder : folder;
|
||||
const hasChildren = Object.keys(tree[folder]).length > 0;
|
||||
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
|
||||
html += `<li class="folder-item">`;
|
||||
if (hasChildren) {
|
||||
const toggleSymbol = (displayState === "none") ? "[+]" : "[-]";
|
||||
const toggleSymbol = (displayState === 'none') ? '[+]' : '[' + '<span class="custom-dash">-</span>' + ']';
|
||||
html += `<span class="folder-toggle" data-folder="${fullPath}">${toggleSymbol}</span>`;
|
||||
} else {
|
||||
html += `<span class="folder-indent-placeholder"></span>`;
|
||||
}
|
||||
// Use escapeHTML to safely render the folder name.
|
||||
html += `<span class="folder-option" data-folder="${fullPath}">${escapeHTML(folder)}</span>`;
|
||||
if (hasChildren) {
|
||||
html += renderFolderTree(tree[folder], fullPath, displayState);
|
||||
@@ -94,7 +196,7 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Expands the folder tree along a given path.
|
||||
// Expands the folder tree along a given normalized path.
|
||||
function expandTreePath(path) {
|
||||
const parts = path.split("/");
|
||||
let cumulative = "";
|
||||
@@ -109,7 +211,7 @@ function expandTreePath(path) {
|
||||
nestedUl.classList.add("expanded");
|
||||
const toggle = li.querySelector(".folder-toggle");
|
||||
if (toggle) {
|
||||
toggle.textContent = "[-]";
|
||||
toggle.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
let state = loadFolderTreeState();
|
||||
state[cumulative] = "block";
|
||||
saveFolderTreeState(state);
|
||||
@@ -122,19 +224,15 @@ function expandTreePath(path) {
|
||||
// ----------------------
|
||||
// Drag & Drop Support for Folder Tree Nodes
|
||||
// ----------------------
|
||||
|
||||
// When a draggable file is dragged over a folder node, allow the drop and add a visual cue.
|
||||
function folderDragOverHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.add("drop-hover");
|
||||
}
|
||||
|
||||
// Remove the visual cue when the drag leaves.
|
||||
function folderDragLeaveHandler(event) {
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
}
|
||||
|
||||
// When a file is dropped onto a folder node, send a move request.
|
||||
function folderDropHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
@@ -143,10 +241,9 @@ function folderDropHandler(event) {
|
||||
try {
|
||||
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
|
||||
} catch (e) {
|
||||
console.error("Invalid drag data");
|
||||
console.error("Invalid drag data", e);
|
||||
return;
|
||||
}
|
||||
// Use the files array if present, or fall back to a single file.
|
||||
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||
if (filesToMove.length === 0) return;
|
||||
fetch("moveFiles.php", {
|
||||
@@ -210,7 +307,7 @@ export async function loadFolderTree(selectedFolder) {
|
||||
}
|
||||
|
||||
let html = `<div id="rootRow" class="root-row">
|
||||
<span class="folder-toggle" data-folder="root">[-]</span>
|
||||
<span class="folder-toggle" data-folder="root">[<span class="custom-dash">-</span>]</span>
|
||||
<span class="folder-option root-folder-option" data-folder="root">(Root)</span>
|
||||
</div>`;
|
||||
if (folders.length === 0) {
|
||||
@@ -232,15 +329,25 @@ export async function loadFolderTree(selectedFolder) {
|
||||
el.addEventListener("drop", folderDropHandler);
|
||||
});
|
||||
|
||||
// Determine current folder.
|
||||
// Determine current folder (normalized).
|
||||
if (selectedFolder) {
|
||||
window.currentFolder = selectedFolder;
|
||||
} else {
|
||||
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
|
||||
}
|
||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||
document.getElementById("fileListTitle").textContent =
|
||||
window.currentFolder === "root" ? "Files in (Root)" : "Files in (" + window.currentFolder + ")";
|
||||
|
||||
// Update file list title using breadcrumb.
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
if (window.currentFolder === "root") {
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb("root") + ")";
|
||||
} else {
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb(window.currentFolder) + ")";
|
||||
}
|
||||
// Bind breadcrumb events (click and drag/drop).
|
||||
bindBreadcrumbEvents();
|
||||
|
||||
// Load file list.
|
||||
loadFileList(window.currentFolder);
|
||||
|
||||
// Expand tree to current folder.
|
||||
@@ -249,13 +356,14 @@ export async function loadFolderTree(selectedFolder) {
|
||||
expandTreePath(window.currentFolder);
|
||||
}
|
||||
|
||||
// Highlight current folder.
|
||||
// Highlight current folder in folder tree.
|
||||
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`);
|
||||
if (selectedEl) {
|
||||
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
||||
selectedEl.classList.add("selected");
|
||||
}
|
||||
|
||||
// Event binding for folder selection.
|
||||
// Event binding for folder selection in folder tree.
|
||||
container.querySelectorAll(".folder-option").forEach(el => {
|
||||
el.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
@@ -264,8 +372,14 @@ export async function loadFolderTree(selectedFolder) {
|
||||
const selected = this.getAttribute("data-folder");
|
||||
window.currentFolder = selected;
|
||||
localStorage.setItem("lastOpenedFolder", selected);
|
||||
document.getElementById("fileListTitle").textContent =
|
||||
selected === "root" ? "Files in (Root)" : "Files in (" + selected + ")";
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
if (selected === "root") {
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb("root") + ")";
|
||||
} else {
|
||||
titleEl.innerHTML = "Files in (" + renderBreadcrumb(selected) + ")";
|
||||
}
|
||||
// Re-bind breadcrumb events so the new breadcrumb is clickable.
|
||||
bindBreadcrumbEvents();
|
||||
loadFileList(selected);
|
||||
});
|
||||
});
|
||||
@@ -281,7 +395,7 @@ export async function loadFolderTree(selectedFolder) {
|
||||
if (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded")) {
|
||||
nestedUl.classList.remove("collapsed");
|
||||
nestedUl.classList.add("expanded");
|
||||
this.textContent = "[-]";
|
||||
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
state["root"] = "block";
|
||||
} else {
|
||||
nestedUl.classList.remove("expanded");
|
||||
@@ -304,7 +418,7 @@ export async function loadFolderTree(selectedFolder) {
|
||||
if (siblingUl.classList.contains("collapsed") || !siblingUl.classList.contains("expanded")) {
|
||||
siblingUl.classList.remove("collapsed");
|
||||
siblingUl.classList.add("expanded");
|
||||
this.textContent = "[-]";
|
||||
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
state[folderPath] = "block";
|
||||
} else {
|
||||
siblingUl.classList.remove("expanded");
|
||||
@@ -332,6 +446,7 @@ export function loadFolderList(selectedFolder) {
|
||||
// ----------------------
|
||||
|
||||
document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal);
|
||||
|
||||
document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal);
|
||||
|
||||
function openRenameFolderModal() {
|
||||
@@ -343,13 +458,19 @@ function openRenameFolderModal() {
|
||||
const parts = selectedFolder.split("/");
|
||||
document.getElementById("newRenameFolderName").value = parts[parts.length - 1];
|
||||
document.getElementById("renameFolderModal").style.display = "block";
|
||||
// Focus the input field after a short delay to ensure modal is visible.
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById("newRenameFolderName");
|
||||
input.focus();
|
||||
input.select();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
document.getElementById("cancelRenameFolder").addEventListener("click", function () {
|
||||
document.getElementById("renameFolderModal").style.display = "none";
|
||||
document.getElementById("newRenameFolderName").value = "";
|
||||
});
|
||||
|
||||
attachEnterKeyListener("renameFolderModal", "submitRenameFolder");
|
||||
document.getElementById("submitRenameFolder").addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
@@ -406,7 +527,7 @@ function openDeleteFolderModal() {
|
||||
document.getElementById("cancelDeleteFolder").addEventListener("click", function () {
|
||||
document.getElementById("deleteFolderModal").style.display = "none";
|
||||
});
|
||||
|
||||
attachEnterKeyListener("deleteFolderModal", "confirmDeleteFolder");
|
||||
document.getElementById("confirmDeleteFolder").addEventListener("click", function () {
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
@@ -437,13 +558,14 @@ document.getElementById("confirmDeleteFolder").addEventListener("click", functio
|
||||
|
||||
document.getElementById("createFolderBtn").addEventListener("click", function () {
|
||||
document.getElementById("createFolderModal").style.display = "block";
|
||||
document.getElementById("newFolderName").focus();
|
||||
});
|
||||
|
||||
document.getElementById("cancelCreateFolder").addEventListener("click", function () {
|
||||
document.getElementById("createFolderModal").style.display = "none";
|
||||
document.getElementById("newFolderName").value = "";
|
||||
});
|
||||
|
||||
attachEnterKeyListener("createFolderModal", "submitCreateFolder");
|
||||
document.getElementById("submitCreateFolder").addEventListener("click", function () {
|
||||
const folderInput = document.getElementById("newFolderName").value.trim();
|
||||
if (!folderInput) {
|
||||
@@ -484,4 +606,150 @@ document.getElementById("submitCreateFolder").addEventListener("click", function
|
||||
console.error("Error creating folder:", error);
|
||||
document.getElementById("createFolderModal").style.display = "none";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
|
||||
|
||||
// Function to display the custom context menu at (x, y) with given menu items.
|
||||
function showFolderManagerContextMenu(x, y, menuItems) {
|
||||
let menu = document.getElementById("folderManagerContextMenu");
|
||||
if (!menu) {
|
||||
menu = document.createElement("div");
|
||||
menu.id = "folderManagerContextMenu";
|
||||
menu.style.position = "absolute";
|
||||
menu.style.padding = "5px 0";
|
||||
menu.style.minWidth = "150px";
|
||||
menu.style.zIndex = "9999";
|
||||
document.body.appendChild(menu);
|
||||
}
|
||||
|
||||
// Set styles based on dark mode.
|
||||
if (document.body.classList.contains("dark-mode")) {
|
||||
menu.style.backgroundColor = "#2c2c2c";
|
||||
menu.style.border = "1px solid #555";
|
||||
menu.style.color = "#e0e0e0";
|
||||
} else {
|
||||
menu.style.backgroundColor = "#fff";
|
||||
menu.style.border = "1px solid #ccc";
|
||||
menu.style.color = "#000";
|
||||
}
|
||||
|
||||
// Clear previous items.
|
||||
menu.innerHTML = "";
|
||||
menuItems.forEach(item => {
|
||||
const menuItem = document.createElement("div");
|
||||
menuItem.textContent = item.label;
|
||||
menuItem.style.padding = "5px 15px";
|
||||
menuItem.style.cursor = "pointer";
|
||||
menuItem.addEventListener("mouseover", () => {
|
||||
if (document.body.classList.contains("dark-mode")) {
|
||||
menuItem.style.backgroundColor = "#444";
|
||||
} else {
|
||||
menuItem.style.backgroundColor = "#f0f0f0";
|
||||
}
|
||||
});
|
||||
menuItem.addEventListener("mouseout", () => {
|
||||
menuItem.style.backgroundColor = "";
|
||||
});
|
||||
menuItem.addEventListener("click", () => {
|
||||
item.action();
|
||||
hideFolderManagerContextMenu();
|
||||
});
|
||||
menu.appendChild(menuItem);
|
||||
});
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
menu.style.display = "block";
|
||||
}
|
||||
|
||||
function hideFolderManagerContextMenu() {
|
||||
const menu = document.getElementById("folderManagerContextMenu");
|
||||
if (menu) {
|
||||
menu.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu handler for folder tree and breadcrumb items.
|
||||
function folderManagerContextMenuHandler(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Get the closest folder element (either from the tree or breadcrumb).
|
||||
const target = e.target.closest(".folder-option, .breadcrumb-link");
|
||||
if (!target) return;
|
||||
|
||||
const folder = target.getAttribute("data-folder");
|
||||
if (!folder) return;
|
||||
|
||||
// Update current folder and highlight the selected element.
|
||||
window.currentFolder = folder;
|
||||
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
|
||||
target.classList.add("selected");
|
||||
|
||||
// Build context menu items.
|
||||
const menuItems = [
|
||||
{
|
||||
label: "Create Folder",
|
||||
action: () => {
|
||||
document.getElementById("createFolderModal").style.display = "block";
|
||||
document.getElementById("newFolderName").focus();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Rename Folder",
|
||||
action: () => { openRenameFolderModal(); }
|
||||
},
|
||||
{
|
||||
label: "Delete Folder",
|
||||
action: () => { openDeleteFolderModal(); }
|
||||
}
|
||||
];
|
||||
|
||||
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||
}
|
||||
|
||||
// Bind contextmenu events to folder tree and breadcrumb elements.
|
||||
function bindFolderManagerContextMenu() {
|
||||
// Bind context menu to folder tree container.
|
||||
const container = document.getElementById("folderTreeContainer");
|
||||
if (container) {
|
||||
container.removeEventListener("contextmenu", folderManagerContextMenuHandler);
|
||||
container.addEventListener("contextmenu", folderManagerContextMenuHandler, false);
|
||||
}
|
||||
|
||||
// Bind context menu to breadcrumb links.
|
||||
const breadcrumbNodes = document.querySelectorAll(".breadcrumb-link");
|
||||
breadcrumbNodes.forEach(node => {
|
||||
node.removeEventListener("contextmenu", folderManagerContextMenuHandler);
|
||||
node.addEventListener("contextmenu", folderManagerContextMenuHandler, false);
|
||||
});
|
||||
}
|
||||
|
||||
// Hide context menu when clicking elsewhere.
|
||||
document.addEventListener("click", function () {
|
||||
hideFolderManagerContextMenu();
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
document.addEventListener("keydown", function(e) {
|
||||
// Skip if the user is typing in an input, textarea, or contentEditable element.
|
||||
const tag = e.target.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// On macOS, "Delete" is typically reported as "Backspace" (keyCode 8)
|
||||
if (e.key === "Delete" || e.key === "Backspace" || e.keyCode === 46 || e.keyCode === 8) {
|
||||
// Ensure a folder is selected and it isn't the root folder.
|
||||
if (window.currentFolder && window.currentFolder !== "root") {
|
||||
// Prevent default (avoid navigating back on macOS).
|
||||
e.preventDefault();
|
||||
// Call your existing folder delete function.
|
||||
openDeleteFolderModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Call this binding function after rendering the folder tree and breadcrumbs.
|
||||
bindFolderManagerContextMenu();
|
||||
43
index.html
43
index.html
@@ -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>
|
||||
|
||||
@@ -91,6 +92,9 @@
|
||||
<button id="logoutBtn" title="Logout">
|
||||
<i class="material-icons">exit_to_app</i>
|
||||
</button>
|
||||
<button id="changePasswordBtn" title="Change Password">
|
||||
<i class="material-icons">vpn_key</i>
|
||||
</button>
|
||||
<!-- Restore Files Modal (Admin Only) -->
|
||||
<div id="restoreFilesModal" class="modal centered-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
@@ -140,6 +144,10 @@
|
||||
<input type="password" class="form-control" id="loginPassword" name="password" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block btn-login">Login</button>
|
||||
<div class="form-group remember-me-container">
|
||||
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
|
||||
<label for="rememberMeCheckbox">Remember me</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,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>
|
||||
@@ -174,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 & Management</span>
|
||||
@@ -292,8 +302,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled>Download ZIP</button>
|
||||
<button id="extractZipBtn" class="btn btn-sm btn-info" title="Extract Zip">Extract Zip</button>
|
||||
<!-- Download Zip Modal -->
|
||||
<div id="downloadZipModal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
@@ -311,14 +321,27 @@
|
||||
</div>
|
||||
</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;">×</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>
|
||||
|
||||
<!-- Add User Modal -->
|
||||
<div id="addUserModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>Create New User</h3>
|
||||
<label for="newUsername">Username:</label>
|
||||
<input type="text" id="newUsername" class="form-control" />
|
||||
<label for="newPassword">Password:</label>
|
||||
<input type="password" id="newPassword" class="form-control" />
|
||||
<label for="addUserPassword">Password:</label>
|
||||
<input type="password" id="addUserPassword" class="form-control" />
|
||||
<div id="adminCheckboxContainer">
|
||||
<input type="checkbox" id="isAdmin" />
|
||||
<label for="isAdmin">Grant Admin Access</label>
|
||||
|
||||
34
logout.php
34
logout.php
@@ -1,19 +1,37 @@
|
||||
<?php
|
||||
session_start();
|
||||
require 'config.php';
|
||||
|
||||
// Retrieve headers and check CSRF token.
|
||||
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
|
||||
|
||||
// Fallback: If a CSRF token exists in the session and doesn't match the one provided,
|
||||
// log the mismatch but proceed with logout.
|
||||
// If there's a mismatch, log it but continue with logout.
|
||||
if (isset($_SESSION['csrf_token']) && $receivedToken !== $_SESSION['csrf_token']) {
|
||||
// Optionally log this event:
|
||||
error_log("CSRF token mismatch on logout. Proceeding with logout.");
|
||||
}
|
||||
|
||||
$_SESSION = []; // Clear session data
|
||||
session_destroy(); // Destroy session
|
||||
// If the remember me token is set, remove it from the persistent tokens file.
|
||||
if (isset($_COOKIE['remember_me_token'])) {
|
||||
$token = $_COOKIE['remember_me_token'];
|
||||
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
|
||||
if (file_exists($persistentTokensFile)) {
|
||||
$encryptedContent = file_get_contents($persistentTokensFile);
|
||||
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
||||
$persistentTokens = json_decode($decryptedContent, true);
|
||||
if (is_array($persistentTokens) && isset($persistentTokens[$token])) {
|
||||
unset($persistentTokens[$token]);
|
||||
$newEncryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
|
||||
file_put_contents($persistentTokensFile, $newEncryptedContent, LOCK_EX);
|
||||
}
|
||||
}
|
||||
// Clear the cookie.
|
||||
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["success" => "Logged out"]);
|
||||
// Clear session data and destroy the session.
|
||||
$_SESSION = [];
|
||||
session_destroy();
|
||||
|
||||
header("Location: index.html");
|
||||
exit;
|
||||
?>
|
||||
@@ -1,4 +1,3 @@
|
||||
// networkUtils.js
|
||||
export function sendRequest(url, method = "GET", data = null) {
|
||||
console.log("Sending request to:", url, "with method:", method);
|
||||
const options = {
|
||||
@@ -24,9 +23,11 @@ export function sendRequest(url, method = "GET", data = null) {
|
||||
throw new Error(`HTTP error ${response.status}: ${text}`);
|
||||
});
|
||||
}
|
||||
// Clone the response so we can safely fall back if JSON parsing fails.
|
||||
const clonedResponse = response.clone();
|
||||
return response.json().catch(() => {
|
||||
console.warn("Response is not JSON, returning as text");
|
||||
return response.text();
|
||||
return clonedResponse.text();
|
||||
});
|
||||
});
|
||||
}
|
||||
63
removeChunks.php
Normal file
63
removeChunks.php
Normal 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);
|
||||
}
|
||||
?>
|
||||
202
styles.css
202
styles.css
@@ -12,20 +12,24 @@ body {
|
||||
|
||||
body {
|
||||
letter-spacing: 0.2px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.custom-dash {
|
||||
display: inline-block;
|
||||
transform: scaleX(1.5);
|
||||
padding-left: 2px !important;
|
||||
padding-right: 2px !important;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -47,7 +51,10 @@ body {
|
||||
/************************************************************/
|
||||
/* FLEXBOX HEADER: LOGO, TITLE, BUTTONS FIXED */
|
||||
/************************************************************/
|
||||
#uploadCard, #folderManagementCard {
|
||||
|
||||
|
||||
#uploadCard,
|
||||
#folderManagementCard {
|
||||
min-height: 342px;
|
||||
}
|
||||
|
||||
@@ -142,14 +149,14 @@ body.dark-mode header {
|
||||
justify-content: flex-end;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
gap: 10px;
|
||||
gap: 0px;
|
||||
}
|
||||
|
||||
.header-buttons button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
padding: 9px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
@@ -292,12 +299,14 @@ body.dark-mode #folderHelpBtn i.material-icons.folder-help-icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.material-icons.folder-icon {
|
||||
.material-icons.folder-icon,
|
||||
.material-icons.gallery-icon {
|
||||
color: black;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
body.dark-mode .material-icons.folder-icon {
|
||||
body.dark-mode .material-icons.folder-icon,
|
||||
body.dark-mode .material-icons.gallery-icon {
|
||||
color: white;
|
||||
margin-right: 5px;
|
||||
}
|
||||
@@ -311,7 +320,7 @@ body.dark-mode .material-icons.folder-icon {
|
||||
border: none;
|
||||
color: red;
|
||||
cursor: pointer;
|
||||
margin-right: 8px;
|
||||
margin-right: 0px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.3s;
|
||||
@@ -325,6 +334,10 @@ body.dark-mode .material-icons.folder-icon {
|
||||
/* ===========================================================
|
||||
FORMS & LOGIN
|
||||
=========================================================== */
|
||||
.remember-me-container {
|
||||
margin-top: 20px !important;
|
||||
}
|
||||
|
||||
#loginForm {
|
||||
margin: 0 auto;
|
||||
max-width: 400px;
|
||||
@@ -521,17 +534,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;
|
||||
@@ -574,12 +576,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;
|
||||
@@ -616,15 +618,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;
|
||||
@@ -634,6 +646,7 @@ body.dark-mode .editor-modal {
|
||||
|
||||
.editor-controls {
|
||||
text-align: right;
|
||||
margin-right: 30px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
@@ -690,6 +703,37 @@ 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;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
body.dark-mode .material-icons.pauseResumeBtn {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
body.dark-mode .material-icons.pauseResumeBtn:hover {
|
||||
background-color: rgba(255, 215, 0, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
#uploadProgressContainer ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -899,6 +943,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;
|
||||
@@ -1129,9 +1174,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 {
|
||||
@@ -1139,9 +1190,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,
|
||||
@@ -1160,7 +1213,7 @@ body.dark-mode #fileListContainer {
|
||||
}
|
||||
|
||||
.col-12.col-md-4.text-left {
|
||||
margin-left: -15px;
|
||||
margin-left: -17px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@@ -1231,6 +1284,27 @@ body.dark-mode #fileListContainer {
|
||||
/* ===========================================================
|
||||
FOLDER TREE STYLES
|
||||
=========================================================== */
|
||||
/* Make breadcrumb links look clickable */
|
||||
.breadcrumb-link {
|
||||
cursor: pointer;
|
||||
color: #007bff;
|
||||
/* Blue color, for example */
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Change color on hover */
|
||||
.breadcrumb-link:hover {
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
/* Style for the selected breadcrumb */
|
||||
.breadcrumb-link.selected {
|
||||
background-color: #e9ecef;
|
||||
font-weight: bold;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.folder-tree {
|
||||
list-style-type: none;
|
||||
padding-left: 10px;
|
||||
@@ -1279,13 +1353,20 @@ body.dark-mode #fileListContainer {
|
||||
.image-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Vertically center the text within a fixed height */
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
text-align: center;
|
||||
min-height: 30px;
|
||||
margin: 0 auto 10px;
|
||||
padding: 10px;
|
||||
width: 90% !important;
|
||||
/* Center horizontally */
|
||||
white-space: nowrap;
|
||||
/* Prevent wrapping */
|
||||
overflow: hidden;
|
||||
/* Hide any overflowing text */
|
||||
text-overflow: ellipsis;
|
||||
/* Truncate with an ellipsis */
|
||||
height: 25px;
|
||||
/* Fixed height for a single line */
|
||||
padding: 5px;
|
||||
margin-bottom: 10px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.image-preview-modal-content {
|
||||
@@ -1567,7 +1648,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;
|
||||
@@ -1579,9 +1660,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;
|
||||
}
|
||||
@@ -1701,7 +1788,7 @@ body.dark-mode .folder-help-summary {
|
||||
body.dark-mode .folder-help-icon {
|
||||
color: #f6a72c;
|
||||
font-size: 20px;
|
||||
|
||||
|
||||
}
|
||||
|
||||
body.dark-mode #searchIcon {
|
||||
@@ -1796,4 +1883,23 @@ body.dark-mode .drop-hover {
|
||||
|
||||
#restoreFilesList li label {
|
||||
margin-left: 8px !important;
|
||||
}
|
||||
|
||||
body.dark-mode #fileContextMenu {
|
||||
background-color: #2c2c2c !important;
|
||||
border: 1px solid #555 !important;
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
body.dark-mode #fileContextMenu div {
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
#folderContextMenu {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
body.dark-mode #folderContextMenu {
|
||||
background-color: #2c2c2c;
|
||||
border-color: #555;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
@@ -4,20 +4,13 @@ import { toggleVisibility, showToast } from './domUtils.js';
|
||||
import { loadFileList } from './fileManager.js';
|
||||
import { loadFolderTree } from './folderManager.js';
|
||||
|
||||
/**
|
||||
* Displays a custom confirmation modal with the given message.
|
||||
* Calls onConfirm() if the user confirms.
|
||||
*/
|
||||
function showConfirm(message, onConfirm) {
|
||||
// Assume your custom confirm modal exists with id "customConfirmModal"
|
||||
// and has elements "confirmMessage", "confirmYesBtn", and "confirmNoBtn".
|
||||
const modal = document.getElementById("customConfirmModal");
|
||||
const messageElem = document.getElementById("confirmMessage");
|
||||
const yesBtn = document.getElementById("confirmYesBtn");
|
||||
const noBtn = document.getElementById("confirmNoBtn");
|
||||
|
||||
if (!modal || !messageElem || !yesBtn || !noBtn) {
|
||||
// Fallback to browser confirm if custom modal is not found.
|
||||
if (confirm(message)) {
|
||||
onConfirm();
|
||||
}
|
||||
@@ -42,12 +35,7 @@ function showConfirm(message, onConfirm) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners for trash restore and delete operations.
|
||||
* 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 +45,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
470
upload.js
@@ -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 Drag–and–Drop 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 file–picker 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 drag–and–drop, 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 Drag–and–Drop (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();
|
||||
|
||||
// Drag–and–drop 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 };
|
||||
279
upload.php
279
upload.php
@@ -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"]);
|
||||
?>
|
||||
@@ -1 +1,7 @@
|
||||
<IfModule mod_php7.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
<IfModule mod_php.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
Options -Indexes
|
||||
Reference in New Issue
Block a user