Add CSRF protections to state-changing endpoints

This commit is contained in:
Ryan
2025-03-18 11:46:23 -04:00
committed by GitHub
parent f709c23bcc
commit d23cefa8a9
23 changed files with 239 additions and 79 deletions

View File

@@ -27,6 +27,7 @@ Multi File Upload Editor is a lightweight, secure web application for uploading,
- The file list can be sorted by name, last modified date, upload date, size, or uploader. For easier browsing, the interface supports pagination with selectable page sizes (10, 20, 50, or 100 items per page) and navigation controls (“Prev”, “Next”, specific page numbers).
- **User Authentication & Management:**
- Secure, session-based authentication protects the editor. An admin user can add or remove users through the interface. Passwords are hashed using PHPs password_hash() for security, and session checks prevent unauthorized access to backend endpoints.
- **CSRF Protection:** All state-changing endpoints (such as those for folder and file operations) include CSRF token validation to ensure that only legitimate requests from authenticated users are processed.
- **Responsive, Dynamic & Persistent UI:**
- The interface is mobile-friendly and adjusts to different screen sizes (hiding non-critical columns on small devices to avoid clutter). Updates to the file list, folder tree, and upload progress happen asynchronously (via Fetch API and XMLHttpRequest), so the page never needs to fully reload. Users receive immediate feedback through toast notifications and modal dialogs for actions like confirmations and error messages, creating a smooth user experience. Persistent UI elements Items Per Page, Dark/Light Mode, folder tree view & last open folder.
- **Dark Mode/Light Mode**

View File

@@ -3,6 +3,13 @@ require 'config.php';
header('Content-Type: application/json');
$usersFile = USERS_DIR . USERS_FILE;
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
if ($receivedToken !== $_SESSION['csrf_token']) {
echo json_encode(["error" => "Invalid CSRF token"]);
http_response_code(403);
exit;
}
// Determine if we are in setup mode:
// - Query parameter setup=1 is passed

24
auth.js
View File

@@ -16,8 +16,8 @@ function initAuth() {
password: document.getElementById("loginPassword").value.trim()
};
console.log("Sending login data:", formData);
// sendRequest already handles credentials if configured in networkUtils.js.
sendRequest("auth.php", "POST", formData)
// Include CSRF token header with login
sendRequest("auth.php", "POST", formData, { "X-CSRF-Token": window.csrfToken })
.then(data => {
console.log("Login response:", data);
if (data.success) {
@@ -35,7 +35,8 @@ function initAuth() {
document.getElementById("logoutBtn").addEventListener("click", function () {
fetch("logout.php", {
method: "POST",
credentials: "include" // Ensure the session cookie is sent.
credentials: "include",
headers: { "X-CSRF-Token": window.csrfToken }
})
.then(() => window.location.reload(true))
.catch(error => console.error("Logout error:", error));
@@ -62,7 +63,10 @@ function initAuth() {
fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
})
.then(response => response.json())
@@ -101,7 +105,10 @@ function initAuth() {
fetch("removeUser.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ username: usernameToRemove })
})
.then(response => response.json())
@@ -129,7 +136,6 @@ function checkAuthentication() {
if (data.setup) {
window.setupMode = true;
showToast("Setup mode: No users found. Please add an admin user.");
// In setup mode, hide login and main operations; show Add User modal.
toggleVisibility("loginForm", false);
toggleVisibility("mainOperations", false);
document.querySelector(".header-buttons").style.visibility = "hidden";
@@ -143,7 +149,6 @@ function checkAuthentication() {
toggleVisibility("mainOperations", true);
toggleVisibility("uploadFileForm", true);
toggleVisibility("fileListContainer", true);
// Show Add/Remove User buttons if admin.
if (data.isAdmin) {
const addUserBtn = document.getElementById("addUserBtn");
const removeUserBtn = document.getElementById("removeUserBtn");
@@ -156,7 +161,6 @@ function checkAuthentication() {
if (removeUserBtn) removeUserBtn.style.display = "none";
}
document.querySelector(".header-buttons").style.visibility = "visible";
// Update persistent items-per-page select once main operations are visible.
const selectElem = document.querySelector(".form-control.bottom-select");
if (selectElem) {
const stored = localStorage.getItem("itemsPerPage") || "10";
@@ -183,18 +187,15 @@ window.checkAuthentication = checkAuthentication;
/* ------------------------------
Persistent Items-Per-Page Setting
------------------------------ */
// When the select value changes, save it to localStorage and refresh the file list.
window.changeItemsPerPage = function (value) {
console.log("Saving itemsPerPage:", value);
localStorage.setItem("itemsPerPage", value);
// Refresh the file list automatically.
const folder = window.currentFolder || "root";
if (typeof renderFileTable === "function") {
renderFileTable(folder);
}
};
// On DOMContentLoaded, set the select to the persisted value.
document.addEventListener("DOMContentLoaded", function () {
const selectElem = document.querySelector(".form-control.bottom-select");
if (selectElem) {
@@ -207,7 +208,6 @@ document.addEventListener("DOMContentLoaded", function () {
/* ------------------------------
Helper functions for modals and user list
------------------------------ */
function resetUserForm() {
document.getElementById("newUsername").value = "";
document.getElementById("newPassword").value = "";

View File

@@ -3,6 +3,13 @@ require 'config.php';
header('Content-Type: application/json');
$usersFile = USERS_DIR . USERS_FILE;
/*$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
if ($receivedToken !== $_SESSION['csrf_token']) {
echo json_encode(["error" => "Invalid CSRF token"]);
http_response_code(403);
exit;
}*/
// Function to authenticate user
function authenticate($username, $password) {

View File

@@ -2,6 +2,9 @@
session_set_cookie_params(7200); // 2 hours in seconds
ini_set('session.gc_maxlifetime', 7200);
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// config.php
define('UPLOAD_DIR', '/var/www/uploads/');
define('BASE_URL', 'http://yourwebsite/uploads/');

View File

@@ -2,6 +2,16 @@
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']) {
echo json_encode(["error" => "Invalid CSRF token"]);
http_response_code(403);
exit;
}
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);

View File

@@ -15,6 +15,15 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit;
}
$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(['success' => false, 'error' => 'Invalid CSRF token.']);
http_response_code(403);
exit;
}
// Get the JSON input and decode it
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['folderName'])) {

View File

@@ -2,6 +2,16 @@
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']) {
echo json_encode(["error" => "Invalid CSRF token"]);
http_response_code(403);
exit;
}
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);

View File

@@ -15,6 +15,15 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit;
}
$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(['success' => false, 'error' => 'Invalid CSRF token.']);
http_response_code(403);
exit;
}
// Get the JSON input and decode it
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['folder'])) {

View File

@@ -1,6 +1,16 @@
<?php
require_once 'config.php';
// --- CSRF Protection ---
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
if ($receivedToken !== $_SESSION['csrf_token']) {
echo json_encode(["error" => "Invalid CSRF token"]);
http_response_code(403);
exit;
}
// Check if the user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);

View File

@@ -1,5 +1,3 @@
// fileManager.js
import {
escapeHTML,
debounce,
@@ -21,11 +19,8 @@ window.itemsPerPage = window.itemsPerPage || 10;
window.currentPage = window.currentPage || 1;
// --- Define formatFolderName ---
// This helper formats folder names for display. Adjust as needed.
function formatFolderName(folder) {
// Example: If folder is "root", return "(Root)"
if (folder === "root") return "(Root)";
// Replace underscores/dashes with spaces and capitalize each word.
return folder
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, char => char.toUpperCase());
@@ -247,7 +242,11 @@ document.addEventListener("DOMContentLoaded", function () {
confirmDelete.addEventListener("click", function () {
fetch("deleteFiles.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ folder: window.currentFolder, files: window.filesToDelete })
})
.then(response => response.json())
@@ -303,7 +302,10 @@ document.addEventListener("DOMContentLoaded", function () {
fetch("downloadZip.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ folder: folder, files: window.filesToDownload })
})
.then(response => {
@@ -396,7 +398,11 @@ document.addEventListener("DOMContentLoaded", function () {
}
fetch("copyFiles.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ source: window.currentFolder, files: window.filesToCopy, destination: targetFolder })
})
.then(response => response.json())
@@ -452,7 +458,11 @@ document.addEventListener("DOMContentLoaded", function () {
}
fetch("moveFiles.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ source: window.currentFolder, files: window.filesToMove, destination: targetFolder })
})
.then(response => response.json())
@@ -632,7 +642,11 @@ export function saveFile(fileName, folder) {
};
fetch("saveFile.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify(fileDataObj)
})
.then(response => response.json())
@@ -708,7 +722,11 @@ document.addEventListener("DOMContentLoaded", () => {
const folderUsed = window.fileFolder;
fetch("renameFile.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ folder: folderUsed, oldName: window.fileToRename, newName: newName })
})
.then(response => response.json())

View File

@@ -267,7 +267,9 @@ document.getElementById("cancelRenameFolder").addEventListener("click", function
document.getElementById("newRenameFolderName").value = "";
});
document.getElementById("submitRenameFolder").addEventListener("click", function () {
document.getElementById("submitRenameFolder").addEventListener("click", function (event) {
event.preventDefault(); // Prevent default form submission
const selectedFolder = window.currentFolder || "root";
const newNameBasename = document.getElementById("newRenameFolderName").value.trim();
if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) {
@@ -276,9 +278,22 @@ document.getElementById("submitRenameFolder").addEventListener("click", function
}
const parentPath = getParentFolder(selectedFolder);
const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename;
// Read the CSRF token from the meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
if (!csrfToken) {
showToast("CSRF token not loaded yet! Please try again.");
return;
}
// Send the rename request with the CSRF token in a custom header
fetch("renameFolder.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", // ensure cookies (and session) are sent
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({ oldFolder: selectedFolder, newFolder: newFolderFull })
})
.then(response => response.json())
@@ -316,9 +331,15 @@ document.getElementById("cancelDeleteFolder").addEventListener("click", function
document.getElementById("confirmDeleteFolder").addEventListener("click", function () {
const selectedFolder = window.currentFolder || "root";
// Read CSRF token from the meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch("deleteFolder.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({ folder: selectedFolder })
})
.then(response => response.json())
@@ -358,10 +379,19 @@ document.getElementById("submitCreateFolder").addEventListener("click", function
if (selectedFolder && selectedFolder !== "root") {
fullFolderName = selectedFolder + "/" + folderInput;
}
// Read CSRF token from the meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch("createFolder.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folderName: folderInput, parent: selectedFolder === "root" ? "" : selectedFolder })
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({
folderName: folderInput,
parent: selectedFolder === "root" ? "" : selectedFolder
})
})
.then(response => response.json())
.then(data => {

View File

@@ -7,6 +7,7 @@
<title>Multi File Upload Editor</title>
<link rel="icon" type="image/png" href="/assets/logo.png">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<meta name="csrf-token" content="" />
<!-- External CSS -->
<link rel="stylesheet" href="styles.css" />
<!-- Google Fonts and Material Icons -->

View File

@@ -1,5 +1,12 @@
<?php
session_start();
$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;
}
$_SESSION = []; // Clear session data
session_destroy(); // Destroy session

19
main.js
View File

@@ -17,6 +17,25 @@ import { loadFolderTree } from './folderManager.js';
import { initUpload } from './upload.js';
import { initAuth, checkAuthentication } from './auth.js';
function loadCsrfToken() {
fetch('token.php', { credentials: 'include' })
.then(response => response.json())
.then(data => {
// Assign to global variable
window.csrfToken = data.csrf_token;
// Also update the meta tag
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'csrf-token';
document.head.appendChild(meta);
}
meta.setAttribute('content', data.csrf_token);
})
.catch(error => console.error("Error loading CSRF token:", error));
}
document.addEventListener("DOMContentLoaded", loadCsrfToken);
// Expose functions for inline handlers.
window.sendRequest = sendRequest;
window.toggleVisibility = toggleVisibility;

View File

@@ -2,6 +2,16 @@
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']) {
echo json_encode(["error" => "Invalid CSRF token"]);
http_response_code(403);
exit;
}
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);

View File

@@ -3,6 +3,13 @@ require 'config.php';
header('Content-Type: application/json');
$usersFile = USERS_DIR . USERS_FILE;
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
if ($receivedToken !== $_SESSION['csrf_token']) {
echo json_encode(["error" => "Invalid CSRF token"]);
http_response_code(403);
exit;
}
// Only allow admins to remove users
if (

View File

@@ -5,6 +5,16 @@ header("Cache-Control: no-cache, no-store, must-revalidate");
header("Pragma: no-cache");
header("Expires: 0");
// --- CSRF Protection ---
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
if ($receivedToken !== $_SESSION['csrf_token']) {
echo json_encode(["error" => "Invalid CSRF token"]);
http_response_code(403);
exit;
}
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);

View File

@@ -18,6 +18,16 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit;
}
// CSRF Protection: Read token from the custom header "X-CSRF-Token"
$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(['success' => false, 'error' => 'Invalid CSRF token.']);
http_response_code(403);
exit;
}
// Get the JSON input and decode it
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['oldFolder']) || !isset($input['newFolder'])) {
@@ -28,22 +38,19 @@ if (!isset($input['oldFolder']) || !isset($input['newFolder'])) {
$oldFolder = trim($input['oldFolder']);
$newFolder = trim($input['newFolder']);
// Allow letters, numbers, underscores, dashes, spaces, and forward slashes
// Validate folder names
if (!preg_match('/^[A-Za-z0-9_\- \/]+$/', $oldFolder) || !preg_match('/^[A-Za-z0-9_\- \/]+$/', $newFolder)) {
echo json_encode(['success' => false, 'error' => 'Invalid folder name(s).']);
exit;
}
// Trim any leading/trailing slashes and spaces.
$oldFolder = trim($oldFolder, "/\\ ");
$newFolder = trim($newFolder, "/\\ ");
// Build full paths relative to UPLOAD_DIR.
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$oldPath = $baseDir . DIRECTORY_SEPARATOR . $oldFolder;
$newPath = $baseDir . DIRECTORY_SEPARATOR . $newFolder;
// Security check: ensure both paths are within the base directory.
if ((realpath($oldPath) === false) || (realpath(dirname($newPath)) === false) ||
strpos(realpath($oldPath), realpath($baseDir)) !== 0 ||
strpos(realpath(dirname($newPath)), realpath($baseDir)) !== 0) {
@@ -51,13 +58,11 @@ if ((realpath($oldPath) === false) || (realpath(dirname($newPath)) === false) ||
exit;
}
// Check if the folder to rename exists.
if (!file_exists($oldPath) || !is_dir($oldPath)) {
echo json_encode(['success' => false, 'error' => 'Folder to rename does not exist.']);
exit;
}
// Check if the new folder name already exists.
if (file_exists($newPath)) {
echo json_encode(['success' => false, 'error' => 'New folder name already exists.']);
exit;

View File

@@ -2,6 +2,16 @@
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']) {
echo json_encode(["error" => "Invalid CSRF token"]);
http_response_code(403);
exit;
}
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);

5
token.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
require 'config.php'; // Must call session_start() and generate CSRF token if not set
header('Content-Type: application/json');
echo json_encode(["csrf_token" => $_SESSION['csrf_token']]);
?>

View File

@@ -7,8 +7,6 @@ function traverseFileTreePromise(item, path = "") {
return new Promise((resolve, reject) => {
if (item.isFile) {
item.file(file => {
// Instead of modifying file.webkitRelativePath (read-only),
// define a new property called "customRelativePath"
Object.defineProperty(file, 'customRelativePath', {
value: path + file.name,
writable: true,
@@ -23,9 +21,7 @@ function traverseFileTreePromise(item, path = "") {
for (let i = 0; i < entries.length; i++) {
promises.push(traverseFileTreePromise(entries[i], path + item.name + "/"));
}
Promise.all(promises).then(results => {
resolve(results.flat());
});
Promise.all(promises).then(results => resolve(results.flat()));
});
} else {
resolve([]);
@@ -46,7 +42,6 @@ function getFilesFromDataTransferItems(items) {
}
// Helper: Set default drop area content.
// Moved to module scope so it is available globally in this module.
function setDropAreaDefault() {
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) {
@@ -104,7 +99,6 @@ function updateFileInfoCount() {
<span id="fileCountDisplay" class="file-name-display">${window.selectedFiles.length} files selected</span>
`;
}
// Show preview of first file.
const previewContainer = document.getElementById("filePreviewContainer");
if (previewContainer && window.selectedFiles.length > 0) {
previewContainer.innerHTML = "";
@@ -120,19 +114,16 @@ function createFileEntry(file) {
li.style.display = "flex";
li.dataset.uploadIndex = file.uploadIndex;
// Create remove button positioned to the left of the preview.
const removeBtn = document.createElement("button");
removeBtn.classList.add("remove-file-btn");
removeBtn.textContent = "×";
removeBtn.addEventListener("click", function (e) {
e.stopPropagation();
// Remove file from global selected files array.
const uploadIndex = file.uploadIndex;
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
li.remove();
updateFileInfoCount();
});
// Store the button so we can hide it later when upload completes.
li.removeBtn = removeBtn;
const preview = document.createElement("div");
@@ -154,8 +145,6 @@ function createFileEntry(file) {
progBar.innerText = "0%";
progDiv.appendChild(progBar);
// Append in order: remove button, preview, name, progress.
li.appendChild(removeBtn);
li.appendChild(preview);
li.appendChild(nameDiv);
@@ -171,7 +160,6 @@ function processFiles(filesInput) {
const fileInfoContainer = document.getElementById("fileInfoContainer");
const files = Array.from(filesInput);
// Update file info container with preview and file count.
if (fileInfoContainer) {
if (files.length > 0) {
if (files.length === 1) {
@@ -195,12 +183,10 @@ function processFiles(filesInput) {
}
}
// Assign unique uploadIndex to each file.
files.forEach((file, index) => {
file.uploadIndex = index;
});
// Build progress list.
const progressContainer = document.getElementById("uploadProgressContainer");
progressContainer.innerHTML = "";
@@ -209,14 +195,12 @@ function processFiles(filesInput) {
const list = document.createElement("ul");
list.classList.add("upload-progress-list");
// Determine grouping using relative path.
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";
@@ -233,15 +217,12 @@ function processFiles(filesInput) {
fileGroups[folderName].push(file);
});
// Create list elements for each folder group.
Object.keys(fileGroups).forEach(folderName => {
// Folder header with Material Icon.
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);
// Nested list for files.
const nestedUl = document.createElement("ul");
nestedUl.classList.add("upload-folder-group-list");
fileGroups[folderName]
@@ -253,7 +234,6 @@ function processFiles(filesInput) {
list.appendChild(nestedUl);
});
} else {
// Flat list.
files.forEach((file, index) => {
const li = createFileEntry(file);
li.style.display = (index < maxDisplay) ? "flex" : "none";
@@ -270,19 +250,15 @@ function processFiles(filesInput) {
}
const listWrapper = document.createElement("div");
listWrapper.classList.add("upload-progress-wrapper");
// Set a maximum height and enable vertical scrolling.
listWrapper.style.maxHeight = "300px";
listWrapper.style.overflowY = "auto";
listWrapper.appendChild(list);
progressContainer.appendChild(listWrapper);
}
// Call once on page load:
adjustFolderHelpExpansion();
// Also call on window resize:
window.addEventListener("resize", adjustFolderHelpExpansion);
// Store files globally for submission.
window.selectedFiles = files;
updateFileInfoCount();
}
@@ -293,7 +269,6 @@ function submitFiles(allFiles) {
const progressContainer = document.getElementById("uploadProgressContainer");
const fileInput = document.getElementById("file");
// Map uploadIndex to progress element.
const progressElements = {};
const listItems = progressContainer.querySelectorAll("li.upload-progress-item");
listItems.forEach(item => {
@@ -308,6 +283,8 @@ function submitFiles(allFiles) {
const formData = new FormData();
formData.append("file[]", file);
formData.append("folder", folderToUse);
// Append CSRF token as "upload_token"
formData.append("upload_token", window.csrfToken);
const relativePath = file.webkitRelativePath || file.customRelativePath || "";
if (relativePath.trim() !== "") {
formData.append("relativePath", relativePath);
@@ -346,7 +323,6 @@ function submitFiles(allFiles) {
if (li) {
li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done";
// Hide the remove button now that upload is done.
if (li.removeBtn) {
li.removeBtn.style.display = "none";
}
@@ -391,6 +367,8 @@ 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);
});
@@ -400,7 +378,6 @@ function submitFiles(allFiles) {
initFileActions();
serverFiles = (serverFiles || []).map(item => item.name.trim().toLowerCase());
allFiles.forEach(file => {
// Skip verification for folder-uploaded files.
if ((file.webkitRelativePath || file.customRelativePath || "").trim() !== "") {
return;
}
@@ -415,7 +392,6 @@ function submitFiles(allFiles) {
});
setTimeout(() => {
if (fileInput) fileInput.value = "";
// Hide remove buttons in progress container.
const removeBtns = progressContainer.querySelectorAll("button.remove-file-btn");
removeBtns.forEach(btn => btn.style.display = "none");
progressContainer.innerHTML = "";
@@ -450,15 +426,12 @@ function initUpload() {
const uploadForm = document.getElementById("uploadFileForm");
if (fileInput) {
// Remove folder selection attributes so clicking the input shows files:
fileInput.removeAttribute("webkitdirectory");
fileInput.removeAttribute("mozdirectory");
fileInput.removeAttribute("directory");
// Allow selecting multiple files.
fileInput.setAttribute("multiple", "");
}
// Set default drop area content.
setDropAreaDefault();
if (dropArea) {

View File

@@ -2,6 +2,17 @@
require_once 'config.php';
header('Content-Type: application/json');
// --- CSRF Protection for Uploads ---
// Use getallheaders() to read the token from the header.
$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;
}
// Ensure user is authenticated
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
@@ -9,9 +20,8 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
exit;
}
// Validate folder name input. Allow letters, numbers, underscores, dashes, spaces, and forward slashes.
// Validate folder name input.
$folder = isset($_POST['folder']) ? trim($_POST['folder']) : 'root';
// When folder is not 'root', allow "/" in the folder name to denote subfolders.
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
echo json_encode(["error" => "Invalid folder name"]);
exit;
@@ -22,7 +32,6 @@ $uploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
if (!is_dir($uploadDir)) {
// Recursively create subfolders as needed.
mkdir($uploadDir, 0775, true);
}
} else {
@@ -36,24 +45,18 @@ $metadataFile = META_DIR . META_FILE;
$metadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
$metadataChanged = false;
// Define a safe pattern for file names: letters, numbers, underscores, dashes, dots, and spaces.
$safeFileNamePattern = '/^[A-Za-z0-9_\-\. ]+$/';
foreach ($_FILES["file"]["name"] as $index => $fileName) {
// Use basename to strip any directory components.
$safeFileName = basename($fileName);
// Validate that the sanitized file name contains only allowed characters.
if (!preg_match($safeFileNamePattern, $safeFileName)) {
echo json_encode(["error" => "Invalid file name: " . $fileName]);
exit;
}
// --- Minimal Folder/Subfolder Logic ---
// Check if a relativePath was provided (from a folder upload)
$relativePath = '';
if (isset($_POST['relativePath'])) {
// In case of multiple files, relativePath may be an array.
if (is_array($_POST['relativePath'])) {
$relativePath = $_POST['relativePath'][$index] ?? '';
} else {
@@ -61,10 +64,8 @@ foreach ($_FILES["file"]["name"] as $index => $fileName) {
}
}
if (!empty($relativePath)) {
// Extract the directory part from the relative path.
$subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') {
// If uploading to root, don't add the "root" folder in the path.
if ($folder === 'root') {
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $subDir . DIRECTORY_SEPARATOR;
} else {
@@ -73,7 +74,6 @@ foreach ($_FILES["file"]["name"] as $index => $fileName) {
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
}
// Use the basename from the relative path.
$safeFileName = basename($relativePath);
}
}
@@ -82,7 +82,6 @@ foreach ($_FILES["file"]["name"] as $index => $fileName) {
$targetPath = $uploadDir . $safeFileName;
if (move_uploaded_file($_FILES["file"]["tmp_name"][$index], $targetPath)) {
// Build the metadata key.
if (!empty($relativePath)) {
$metaKey = ($folder !== 'root') ? $folder . "/" . $relativePath : $relativePath;
} else {