}
+ */
+export async function fetchWithCsrf(url, options = {}) {
+ options = { credentials: 'include', headers: {}, ...options };
+ options.headers['X-CSRF-Token'] = window.csrfToken;
+
+ // 1) First attempt using the original fetch
+ let res = await originalFetch(url, options);
+
+ // 2) Soft‐failure JSON check (200 + {csrf_expired})
+ if (res.ok && res.headers.get('content-type')?.includes('application/json')) {
+ const clone = res.clone();
+ const data = await clone.json();
+ if (data.csrf_expired) {
+ const newToken = data.csrf_token;
+ window.csrfToken = newToken;
+ document.querySelector('meta[name="csrf-token"]').content = newToken;
+ options.headers['X-CSRF-Token'] = newToken;
+ return originalFetch(url, options);
+ }
+ }
+
+ // 3) HTTP 403 fallback
+ if (res.status === 403) {
+ let newToken = res.headers.get('X-CSRF-Token');
+ if (!newToken) {
+ const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' });
+ if (tokRes.ok) {
+ const body = await tokRes.json();
+ newToken = body.csrf_token;
+ }
+ }
+ if (newToken) {
+ window.csrfToken = newToken;
+ document.querySelector('meta[name="csrf-token"]').content = newToken;
+ options.headers['X-CSRF-Token'] = newToken;
+ res = await originalFetch(url, options);
+ }
+ }
+
+ return res;
+}
+
+
// wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows
function openTOTPLoginModal() {
originalOpenTOTPLoginModal();
@@ -236,6 +285,10 @@ function checkAuthentication(showLoginToast = true) {
if (typeof data.totp_enabled !== "undefined") {
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
}
+ if (data.csrf_token) {
+ window.csrfToken = data.csrf_token;
+ document.querySelector('meta[name="csrf-token"]').content = data.csrf_token;
+ }
updateAuthenticatedUI(data);
return data;
} else {
@@ -277,11 +330,11 @@ async function submitLogin(data) {
try {
const perm = await sendRequest("/api/getUserPermissions.php", "GET");
if (perm && typeof perm === "object") {
- localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
- localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
- localStorage.setItem("disableUpload",perm.disableUpload? "true" : "false");
+ localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
+ localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
+ localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false");
}
- } catch {}
+ } catch { }
return window.location.reload();
}
@@ -406,10 +459,10 @@ function initAuth() {
}
let url = "/api/addUser.php";
if (window.setupMode) url += "?setup=1";
- fetch(url, {
+ fetchWithCsrf(url, {
method: "POST",
credentials: "include",
- headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
})
.then(response => response.json())
@@ -439,10 +492,10 @@ function initAuth() {
}
const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?");
if (!confirmed) return;
- fetch("/api/removeUser.php", {
+ fetchWithCsrf("/api/removeUser.php", {
method: "POST",
credentials: "include",
- headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: usernameToRemove })
})
.then(response => response.json())
@@ -478,10 +531,10 @@ function initAuth() {
return;
}
const data = { oldPassword, newPassword, confirmPassword };
- fetch("/api/changePassword.php", {
+ fetchWithCsrf("/api/changePassword.php", {
method: "POST",
credentials: "include",
- headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
})
.then(response => response.json())
diff --git a/public/js/authModals.js b/public/js/authModals.js
index 4dd47f2..8ae7881 100644
--- a/public/js/authModals.js
+++ b/public/js/authModals.js
@@ -3,7 +3,7 @@ import { sendRequest } from './networkUtils.js';
import { t, applyTranslations, setLocale } from './i18n.js';
import { loadAdminConfigFunc } from './auth.js';
-const version = "v1.2.3"; // Update this version string as needed
+const version = "v1.2.4"; // Update this version string as needed
const adminTitle = `${t("admin_panel")} ${version}`;
let lastLoginData = null;
diff --git a/public/js/folderManager.js b/public/js/folderManager.js
index 4be16cf..cee7489 100644
--- a/public/js/folderManager.js
+++ b/public/js/folderManager.js
@@ -4,6 +4,8 @@ import { loadFileList } from './fileListView.js';
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
import { t } from './i18n.js';
import { openFolderShareModal } from './folderShareModal.js';
+import { fetchWithCsrf } from './auth.js';
+import { loadCsrfToken } from './main.js';
/* ----------------------
Helper Functions (Data/State)
@@ -337,7 +339,7 @@ export async function loadFolderTree(selectedFolder) {
try {
// Check if the user has folder-only permission.
await checkUserFolderPermission();
-
+
// Determine effective root folder.
const username = localStorage.getItem("username") || "root";
let effectiveRoot = "root";
@@ -351,14 +353,14 @@ export async function loadFolderTree(selectedFolder) {
} else {
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
}
-
+
// Build fetch URL.
let fetchUrl = '/api/folder/getFolderList.php';
if (window.userFolderOnly) {
fetchUrl += '?restricted=1';
}
console.log("Fetching folder list from:", fetchUrl);
-
+
// Fetch folder list from the server.
const response = await fetch(fetchUrl);
if (response.status === 401) {
@@ -375,10 +377,10 @@ export async function loadFolderTree(selectedFolder) {
} else if (Array.isArray(folderData)) {
folders = folderData;
}
-
+
// Remove any global "root" entry.
folders = folders.filter(folder => folder.toLowerCase() !== "root");
-
+
// If restricted, filter folders: keep only those that start with effectiveRoot + "/" (do not include effectiveRoot itself).
if (window.userFolderOnly && effectiveRoot !== "root") {
folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/"));
@@ -386,16 +388,16 @@ export async function loadFolderTree(selectedFolder) {
localStorage.setItem("lastOpenedFolder", effectiveRoot);
window.currentFolder = effectiveRoot;
}
-
+
localStorage.setItem("lastOpenedFolder", window.currentFolder);
-
+
// Render the folder tree.
const container = document.getElementById("folderTreeContainer");
if (!container) {
console.error("Folder tree container not found.");
return;
}
-
+
let html = `
[-]
${effectiveLabel}
@@ -405,35 +407,35 @@ export async function loadFolderTree(selectedFolder) {
html += renderFolderTree(tree, "", "block");
}
container.innerHTML = html;
-
+
// Attach drag/drop event listeners.
container.querySelectorAll(".folder-option").forEach(el => {
el.addEventListener("dragover", folderDragOverHandler);
el.addEventListener("dragleave", folderDragLeaveHandler);
el.addEventListener("drop", folderDropHandler);
});
-
+
if (selectedFolder) {
window.currentFolder = selectedFolder;
}
localStorage.setItem("lastOpenedFolder", window.currentFolder);
-
+
const titleEl = document.getElementById("fileListTitle");
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(window.currentFolder) + ")";
setupBreadcrumbDelegation();
loadFileList(window.currentFolder);
-
+
const folderState = loadFolderTreeState();
if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") {
expandTreePath(window.currentFolder);
}
-
+
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");
}
-
+
container.querySelectorAll(".folder-option").forEach(el => {
el.addEventListener("click", function (e) {
e.stopPropagation();
@@ -448,7 +450,7 @@ export async function loadFolderTree(selectedFolder) {
loadFileList(selected);
});
});
-
+
const rootToggle = container.querySelector("#rootRow .folder-toggle");
if (rootToggle) {
rootToggle.addEventListener("click", function (e) {
@@ -471,7 +473,7 @@ export async function loadFolderTree(selectedFolder) {
}
});
}
-
+
container.querySelectorAll(".folder-toggle").forEach(toggle => {
toggle.addEventListener("click", function (e) {
e.stopPropagation();
@@ -494,7 +496,7 @@ export async function loadFolderTree(selectedFolder) {
}
});
});
-
+
} catch (error) {
console.error("Error loading folder tree:", error);
}
@@ -627,45 +629,53 @@ document.getElementById("cancelCreateFolder").addEventListener("click", function
document.getElementById("newFolderName").value = "";
});
attachEnterKeyListener("createFolderModal", "submitCreateFolder");
-document.getElementById("submitCreateFolder").addEventListener("click", function () {
+document.getElementById("submitCreateFolder").addEventListener("click", async () => {
const folderInput = document.getElementById("newFolderName").value.trim();
- if (!folderInput) {
- showToast("Please enter a folder name.");
- return;
+ if (!folderInput) return showToast("Please enter a folder name.");
+
+ const selectedFolder = window.currentFolder || "root";
+ const parent = selectedFolder === "root" ? "" : selectedFolder;
+
+ // 1) Guarantee fresh CSRF
+ try {
+ await loadCsrfToken();
+ } catch {
+ return showToast("Could not refresh CSRF token. Please reload.");
}
- let selectedFolder = window.currentFolder || "root";
- let fullFolderName = folderInput;
- if (selectedFolder && selectedFolder !== "root") {
- fullFolderName = selectedFolder + "/" + folderInput;
- }
- const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
- fetch("/api/folder/createFolder.php", {
+
+ // 2) Call with fetchWithCsrf
+ fetchWithCsrf("/api/folder/createFolder.php", {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- "X-CSRF-Token": csrfToken
- },
- body: JSON.stringify({
- folderName: folderInput,
- parent: selectedFolder === "root" ? "" : selectedFolder
- })
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ folderName: folderInput, parent })
})
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- showToast("Folder created successfully!");
- window.currentFolder = fullFolderName;
- localStorage.setItem("lastOpenedFolder", fullFolderName);
- loadFolderList(fullFolderName);
- } else {
- showToast("Error: " + (data.error || "Could not create folder"));
+ .then(async res => {
+ if (!res.ok) {
+ // pull out a JSON error, or fallback to status text
+ let err;
+ try {
+ const j = await res.json();
+ err = j.error || j.message || res.statusText;
+ } catch {
+ err = res.statusText;
+ }
+ throw new Error(err);
}
+ return res.json();
+ })
+ .then(data => {
+ showToast("Folder created!");
+ const full = parent ? `${parent}/${folderInput}` : folderInput;
+ window.currentFolder = full;
+ localStorage.setItem("lastOpenedFolder", full);
+ loadFolderList(full);
+ })
+ .catch(e => {
+ showToast("Error creating folder: " + e.message);
+ })
+ .finally(() => {
document.getElementById("createFolderModal").style.display = "none";
document.getElementById("newFolderName").value = "";
- })
- .catch(error => {
- console.error("Error creating folder:", error);
- document.getElementById("createFolderModal").style.display = "none";
});
});
diff --git a/public/js/main.js b/public/js/main.js
index 742ff74..bc17dbf 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -1,8 +1,10 @@
import { sendRequest } from './networkUtils.js';
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
-import { loadFolderTree } from './folderManager.js';
import { initUpload } from './upload.js';
-import { initAuth, checkAuthentication, loadAdminConfigFunc } from './auth.js';
+import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js';
+const _originalFetch = window.fetch;
+window.fetch = fetchWithCsrf;
+import { loadFolderTree } from './folderManager.js';
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js';
@@ -13,35 +15,53 @@ import { editFile, saveFile } from './fileEditor.js';
import { t, applyTranslations, setLocale } from './i18n.js';
// Remove the retry logic version and just use loadCsrfToken directly:
-function loadCsrfToken() {
- return fetch('/api/auth/token.php', { credentials: 'include' })
+/**
+ * Fetches the current CSRF token (and share URL), updates window globals
+ * and tags, and returns the data.
+ *
+ * @returns {Promise<{csrf_token: string, share_url: string}>}
+ */
+export function loadCsrfToken() {
+ return fetch('/api/auth/token.php', {
+ method: 'GET',
+ credentials: 'include'
+ })
.then(response => {
if (!response.ok) {
- throw new Error("Token fetch failed with status: " + response.status);
+ throw new Error(`Token fetch failed with status: ${response.status}`);
}
- return response.json();
+ // Prefer header if set, otherwise fall back to body
+ const headerToken = response.headers.get('X-CSRF-Token');
+ return response.json()
+ .then(body => ({
+ csrf_token: headerToken || body.csrf_token,
+ share_url: body.share_url
+ }));
})
- .then(data => {
- window.csrfToken = data.csrf_token;
- window.SHARE_URL = data.share_url;
-
- let metaCSRF = document.querySelector('meta[name="csrf-token"]');
- if (!metaCSRF) {
- metaCSRF = document.createElement('meta');
- metaCSRF.name = 'csrf-token';
- document.head.appendChild(metaCSRF);
- }
- metaCSRF.setAttribute('content', data.csrf_token);
+ .then(({ csrf_token, share_url }) => {
+ // Update globals
+ window.csrfToken = csrf_token;
+ window.SHARE_URL = share_url;
- let metaShare = document.querySelector('meta[name="share-url"]');
- if (!metaShare) {
- metaShare = document.createElement('meta');
- metaShare.name = 'share-url';
- document.head.appendChild(metaShare);
+ // Sync
+ let meta = document.querySelector('meta[name="csrf-token"]');
+ if (!meta) {
+ meta = document.createElement('meta');
+ meta.name = 'csrf-token';
+ document.head.appendChild(meta);
}
- metaShare.setAttribute('content', data.share_url);
+ meta.content = csrf_token;
- return data;
+ // Sync
+ let shareMeta = document.querySelector('meta[name="share-url"]');
+ if (!shareMeta) {
+ shareMeta = document.createElement('meta');
+ shareMeta.name = 'share-url';
+ document.head.appendChild(shareMeta);
+ }
+ shareMeta.content = share_url;
+
+ return { csrf_token, share_url };
});
}
diff --git a/public/js/upload.js b/public/js/upload.js
index 671afef..1355ae3 100644
--- a/public/js/upload.js
+++ b/public/js/upload.js
@@ -412,7 +412,12 @@ function initResumableUpload() {
forceChunkSize: true,
testChunks: false,
throttleProgressCallbacks: 1,
- headers: { "X-CSRF-Token": window.csrfToken }
+ withCredentials: true,
+ headers: { 'X-CSRF-Token': window.csrfToken },
+ query: {
+ folder: window.currentFolder || "root",
+ upload_token: window.csrfToken // still as a fallback
+ }
});
const fileInput = document.getElementById("file");
@@ -496,26 +501,40 @@ function initResumableUpload() {
});
resumableInstance.on("fileSuccess", function(file, message) {
- const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
+ // Try to parse JSON response
+ let data;
+ try {
+ data = JSON.parse(message);
+ } catch (e) {
+ data = null;
+ }
+
+ // 1) Soft‐fail CSRF? then update token & retry this file
+ if (data && data.csrf_expired) {
+ // Update global and Resumable headers
+ window.csrfToken = data.csrf_token;
+ resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token;
+ resumableInstance.opts.query.upload_token = data.csrf_token;
+ // Retry this chunk/file
+ file.retry();
+ return;
+ }
+
+ // 2) Otherwise treat as real success:
+ 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 pause/resume and remove buttons for successful files.
+ // remove action buttons
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
- if (pauseResumeBtn) {
- pauseResumeBtn.style.display = "none";
- }
+ if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
const removeBtn = li.querySelector(".remove-file-btn");
- if (removeBtn) {
- removeBtn.style.display = "none";
- }
- // Schedule removal of the file entry after 5 seconds.
- setTimeout(() => {
- li.remove();
- window.selectedFiles = window.selectedFiles.filter(f => f.uniqueIdentifier !== file.uniqueIdentifier);
- updateFileInfoCount();
- }, 5000);
+ if (removeBtn) removeBtn.style.display = "none";
+ setTimeout(() => li.remove(), 5000);
}
+
loadFileList(window.currentFolder);
});
@@ -618,8 +637,25 @@ function submitFiles(allFiles) {
} catch (e) {
jsonResponse = null;
}
+
+ // ─── Soft-fail CSRF: retry this upload ───────────────────────
+ if (jsonResponse && jsonResponse.csrf_expired) {
+ console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
+ // 1) update global token + header
+ window.csrfToken = jsonResponse.csrf_token;
+ xhr.open("POST", "/api/upload/upload.php", true);
+ xhr.withCredentials = true;
+ xhr.setRequestHeader("X-CSRF-Token", window.csrfToken);
+ // 2) re-send the same formData
+ xhr.send(formData);
+ return; // skip the "finishedCount++" and error/success logic for now
+ }
+
+ // ─── Normal success/error handling ────────────────────────────
const li = progressElements[file.uploadIndex];
+
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
+ // real success
if (li) {
li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done";
@@ -627,11 +663,14 @@ function submitFiles(allFiles) {
}
uploadResults[file.uploadIndex] = true;
} else {
+ // real failure
if (li) {
li.progressBar.innerText = "Error";
}
allSucceeded = false;
}
+
+ // ─── Only now count this chunk as finished ───────────────────
finishedCount++;
if (finishedCount === allFiles.length) {
refreshFileList(allFiles, uploadResults, progressElements);
@@ -665,6 +704,7 @@ function submitFiles(allFiles) {
});
xhr.open("POST", "/api/upload/upload.php", true);
+ xhr.withCredentials = true;
xhr.setRequestHeader("X-CSRF-Token", window.csrfToken);
xhr.send(formData);
});
diff --git a/src/controllers/authController.php b/src/controllers/authController.php
index 3166ffa..18cc089 100644
--- a/src/controllers/authController.php
+++ b/src/controllers/authController.php
@@ -346,7 +346,9 @@ class AuthController
if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) {
$payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']);
if ($payload) {
+ $old = $_SESSION['csrf_token'] ?? bin2hex(random_bytes(32));
session_regenerate_id(true);
+ $_SESSION['csrf_token'] = $old;
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $payload['username'];
$_SESSION['isAdmin'] = !empty($payload['isAdmin']);
@@ -354,7 +356,7 @@ class AuthController
$_SESSION['readOnly'] = $payload['readOnly'] ?? false;
$_SESSION['disableUpload'] = $payload['disableUpload'] ?? false;
// regenerate CSRF if you use one
- $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+
// TOTP enabled? (same logic as below)
$usersFile = USERS_DIR . USERS_FILE;
@@ -371,6 +373,7 @@ class AuthController
echo json_encode([
'authenticated' => true,
+ 'csrf_token' => $_SESSION['csrf_token'],
'isAdmin' => $_SESSION['isAdmin'],
'totp_enabled' => $totp,
'username' => $_SESSION['username'],
@@ -446,10 +449,19 @@ class AuthController
*/
public function getToken(): void
{
+ // 1) Ensure session and CSRF token exist
+ if (empty($_SESSION['csrf_token'])) {
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ }
+
+ // 2) Emit headers
header('Content-Type: application/json');
+ header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
+
+ // 3) Return JSON payload
echo json_encode([
- "csrf_token" => $_SESSION['csrf_token'],
- "share_url" => SHARE_URL
+ 'csrf_token' => $_SESSION['csrf_token'],
+ 'share_url' => SHARE_URL
]);
exit;
}
diff --git a/src/controllers/uploadController.php b/src/controllers/uploadController.php
index eea9b65..f9bc2c8 100644
--- a/src/controllers/uploadController.php
+++ b/src/controllers/uploadController.php
@@ -72,34 +72,56 @@ class UploadController {
*/
public function handleUpload(): void {
header('Content-Type: application/json');
-
- // CSRF Protection.
+
+ //
+ // 1) CSRF – pull from header or POST fields
+ //
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
- $receivedToken = $headersArr['x-csrf-token'] ?? '';
- if (!isset($_SESSION['csrf_token']) || trim($receivedToken) !== $_SESSION['csrf_token']) {
- http_response_code(403);
- echo json_encode(["error" => "Invalid CSRF token"]);
+ $received = '';
+ if (!empty($headersArr['x-csrf-token'])) {
+ $received = trim($headersArr['x-csrf-token']);
+ } elseif (!empty($_POST['csrf_token'])) {
+ $received = trim($_POST['csrf_token']);
+ } elseif (!empty($_POST['upload_token'])) {
+ $received = trim($_POST['upload_token']);
+ }
+
+ // 1a) If it doesn’t match, soft-fail: send new token and let client retry
+ if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
+ // regenerate
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ // tell client “please retry with this new token”
+ http_response_code(200);
+ echo json_encode([
+ 'csrf_expired' => true,
+ 'csrf_token' => $_SESSION['csrf_token']
+ ]);
exit;
}
- // Ensure user is authenticated.
- if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
+
+ //
+ // 2) Auth checks
+ //
+ if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
- // Check user permissions.
- $username = $_SESSION['username'] ?? '';
- $userPermissions = loadUserPermissions($username);
- if ($username && !empty($userPermissions['disableUpload'])) {
+ $userPerms = loadUserPermissions($_SESSION['username']);
+ if (!empty($userPerms['disableUpload'])) {
http_response_code(403);
echo json_encode(["error" => "Upload disabled for this user."]);
exit;
}
-
- // Delegate to the model.
+
+ //
+ // 3) Delegate the actual file handling
+ //
$result = UploadModel::handleUpload($_POST, $_FILES);
-
- // For chunked uploads, output JSON (e.g., "chunk uploaded" status).
+
+ //
+ // 4) Respond
+ //
if (isset($result['error'])) {
http_response_code(400);
echo json_encode($result);
@@ -109,8 +131,8 @@ class UploadController {
echo json_encode($result);
exit;
}
-
- // Otherwise, for full upload success, set a flash message and redirect.
+
+ // full‐upload redirect
$_SESSION['upload_message'] = "File uploaded successfully.";
exit;
}
diff --git a/src/controllers/userController.php b/src/controllers/userController.php
index fb7b1bc..c8a51db 100644
--- a/src/controllers/userController.php
+++ b/src/controllers/userController.php
@@ -87,63 +87,83 @@ class UserController
public function addUser()
{
+ // 1) Ensure JSON output and session
header('Content-Type: application/json');
- $usersFile = USERS_DIR . USERS_FILE;
+ // 1a) Initialize CSRF token if missing
+ if (empty($_SESSION['csrf_token'])) {
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ }
- // Determine if we're in setup mode.
- // Setup mode means the "setup" query parameter is passed
- // and users.txt is missing, empty, or contains only whitespace.
- $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 or CSRF checks.
+ // 2) Determine setup mode (first-ever admin creation)
+ $usersFile = USERS_DIR . USERS_FILE;
+ $isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
+ $setupMode = false;
+ if (
+ $isSetup && (! file_exists($usersFile)
+ || filesize($usersFile) === 0
+ || trim(file_get_contents($usersFile)) === ''
+ )
+ ) {
$setupMode = true;
} else {
- $setupMode = false;
- // In non-setup mode, perform CSRF token and authentication checks.
- $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
- $receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
- if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
- http_response_code(403);
- echo json_encode(["error" => "Invalid CSRF token"]);
+ // 3) In non-setup, enforce CSRF + auth checks
+ $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
+ $receivedToken = trim($headersArr['x-csrf-token'] ?? '');
+
+ // 3a) Soft-fail CSRF: on mismatch, regenerate and return new token
+ if ($receivedToken !== $_SESSION['csrf_token']) {
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
+ echo json_encode([
+ 'csrf_expired' => true,
+ 'csrf_token' => $_SESSION['csrf_token']
+ ]);
exit;
}
+
+ // 3b) Must be logged in as admin
if (
- !isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
- !isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
+ empty($_SESSION['authenticated'])
+ || $_SESSION['authenticated'] !== true
+ || empty($_SESSION['isAdmin'])
+ || $_SESSION['isAdmin'] !== true
) {
echo json_encode(["error" => "Unauthorized"]);
exit;
}
}
- // Get the JSON input data.
- $data = json_decode(file_get_contents("php://input"), true);
- $newUsername = trim($data["username"] ?? "");
- $newPassword = trim($data["password"] ?? "");
+ // 4) Parse input
+ $data = json_decode(file_get_contents('php://input'), true) ?: [];
+ $newUsername = trim($data['username'] ?? '');
+ $newPassword = trim($data['password'] ?? '');
- // In setup mode, force the new user to be an admin.
+ // 5) Determine admin flag
if ($setupMode) {
- $isAdmin = "1";
+ $isAdmin = '1';
} else {
- $isAdmin = !empty($data["isAdmin"]) ? "1" : "0";
+ $isAdmin = !empty($data['isAdmin']) ? '1' : '0';
}
- // Validate that a username and password are provided.
- if (!$newUsername || !$newPassword) {
+ // 6) Validate fields
+ if ($newUsername === '' || $newPassword === '') {
echo json_encode(["error" => "Username and password required"]);
exit;
}
-
- // Validate username format.
if (!preg_match(REGEX_USER, $newUsername)) {
- echo json_encode(["error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
+ echo json_encode([
+ "error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."
+ ]);
exit;
}
- // Delegate the business logic to the model.
+ // 7) Delegate to model
$result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
+
+ // 8) Return model result
echo json_encode($result);
+ exit;
}
/**
@@ -847,148 +867,151 @@ class UserController
* )
*/
- public function verifyTOTP()
- {
- header('Content-Type: application/json');
- header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
-
- // Rate‑limit
- if (!isset($_SESSION['totp_failures'])) {
- $_SESSION['totp_failures'] = 0;
- }
- if ($_SESSION['totp_failures'] >= 5) {
- http_response_code(429);
- echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
- exit;
- }
-
- // Must be authenticated OR pending login
- if (!((!empty($_SESSION['authenticated'])) || isset($_SESSION['pending_login_user']))) {
- http_response_code(403);
- echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
- exit;
- }
-
- // CSRF check
- $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
- $csrfHeader = $headersArr['x-csrf-token'] ?? '';
- if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
- http_response_code(403);
- echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
- exit;
- }
-
- // Parse and validate input
- $inputData = json_decode(file_get_contents("php://input"), true);
- $code = trim($inputData['totp_code'] ?? '');
- if (!preg_match('/^\d{6}$/', $code)) {
- http_response_code(400);
- echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
- exit;
- }
-
- // TFA helper
- $tfa = new \RobThree\Auth\TwoFactorAuth(
- new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
- 'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1
- );
-
- // Pending‑login flow (first password step passed)
- if (isset($_SESSION['pending_login_user'])) {
- $username = $_SESSION['pending_login_user'];
- $pendingSecret = $_SESSION['pending_login_secret'] ?? null;
- $rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
-
- if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
- $_SESSION['totp_failures']++;
- http_response_code(400);
- echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
- exit;
- }
-
- // === Issue “remember me” token if requested ===
- if ($rememberMe) {
- $tokFile = USERS_DIR . 'persistent_tokens.json';
- $token = bin2hex(random_bytes(32));
- $expiry = time() + 30 * 24 * 60 * 60;
- $all = [];
-
- if (file_exists($tokFile)) {
- $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
- $all = json_decode($dec, true) ?: [];
- }
- $isAdmin = ((int)userModel::getUserRole($username) === 1);
- $all[$token] = [
- 'username' => $username,
- 'expiry' => $expiry,
- 'isAdmin' => $isAdmin
- ];
- file_put_contents(
- $tokFile,
- encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
- LOCK_EX
- );
-
- $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
-
- // Persistent cookie
- setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
-
- // Re‑issue PHP session cookie
- setcookie(
- session_name(),
- session_id(),
- $expiry,
- '/',
- '',
- $secure,
- true
- );
- }
-
- // Finalize login
- session_regenerate_id(true);
- $_SESSION['authenticated'] = true;
- $_SESSION['username'] = $username;
- $_SESSION['isAdmin'] = $isAdmin;
- $_SESSION['folderOnly'] = loadUserPermissions($username);
-
- // Clean up
- unset(
- $_SESSION['pending_login_user'],
- $_SESSION['pending_login_secret'],
- $_SESSION['pending_login_remember_me'],
- $_SESSION['totp_failures']
- );
-
- echo json_encode(['status' => 'ok', 'message' => 'Login successful']);
- exit;
- }
-
- // Setup/verification flow (not pending)
- $username = $_SESSION['username'] ?? '';
- if (!$username) {
- http_response_code(400);
- echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
- exit;
- }
-
- $totpSecret = userModel::getTOTPSecret($username);
- if (!$totpSecret) {
- http_response_code(500);
- echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
- exit;
- }
-
- if (!$tfa->verifyCode($totpSecret, $code)) {
- $_SESSION['totp_failures']++;
- http_response_code(400);
- echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
- exit;
- }
-
- // Successful setup/verification
- unset($_SESSION['totp_failures']);
- echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
- }
+ public function verifyTOTP()
+ {
+ header('Content-Type: application/json');
+ header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
+
+ // Rate‑limit
+ if (!isset($_SESSION['totp_failures'])) {
+ $_SESSION['totp_failures'] = 0;
+ }
+ if ($_SESSION['totp_failures'] >= 5) {
+ http_response_code(429);
+ echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
+ exit;
+ }
+
+ // Must be authenticated OR pending login
+ if (!((!empty($_SESSION['authenticated'])) || isset($_SESSION['pending_login_user']))) {
+ http_response_code(403);
+ echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
+ exit;
+ }
+
+ // CSRF check
+ $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
+ $csrfHeader = $headersArr['x-csrf-token'] ?? '';
+ if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
+ http_response_code(403);
+ echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
+ exit;
+ }
+
+ // Parse and validate input
+ $inputData = json_decode(file_get_contents("php://input"), true);
+ $code = trim($inputData['totp_code'] ?? '');
+ if (!preg_match('/^\d{6}$/', $code)) {
+ http_response_code(400);
+ echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
+ exit;
+ }
+
+ // TFA helper
+ $tfa = new \RobThree\Auth\TwoFactorAuth(
+ new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
+ 'FileRise',
+ 6,
+ 30,
+ \RobThree\Auth\Algorithm::Sha1
+ );
+
+ // Pending‑login flow (first password step passed)
+ if (isset($_SESSION['pending_login_user'])) {
+ $username = $_SESSION['pending_login_user'];
+ $pendingSecret = $_SESSION['pending_login_secret'] ?? null;
+ $rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
+
+ if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
+ $_SESSION['totp_failures']++;
+ http_response_code(400);
+ echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
+ exit;
+ }
+
+ // === Issue “remember me” token if requested ===
+ if ($rememberMe) {
+ $tokFile = USERS_DIR . 'persistent_tokens.json';
+ $token = bin2hex(random_bytes(32));
+ $expiry = time() + 30 * 24 * 60 * 60;
+ $all = [];
+
+ if (file_exists($tokFile)) {
+ $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
+ $all = json_decode($dec, true) ?: [];
+ }
+ $isAdmin = ((int)userModel::getUserRole($username) === 1);
+ $all[$token] = [
+ 'username' => $username,
+ 'expiry' => $expiry,
+ 'isAdmin' => $isAdmin
+ ];
+ file_put_contents(
+ $tokFile,
+ encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
+ LOCK_EX
+ );
+
+ $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
+
+ // Persistent cookie
+ setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
+
+ // Re‑issue PHP session cookie
+ setcookie(
+ session_name(),
+ session_id(),
+ $expiry,
+ '/',
+ '',
+ $secure,
+ true
+ );
+ }
+
+ // Finalize login
+ session_regenerate_id(true);
+ $_SESSION['authenticated'] = true;
+ $_SESSION['username'] = $username;
+ $_SESSION['isAdmin'] = $isAdmin;
+ $_SESSION['folderOnly'] = loadUserPermissions($username);
+
+ // Clean up
+ unset(
+ $_SESSION['pending_login_user'],
+ $_SESSION['pending_login_secret'],
+ $_SESSION['pending_login_remember_me'],
+ $_SESSION['totp_failures']
+ );
+
+ echo json_encode(['status' => 'ok', 'message' => 'Login successful']);
+ exit;
+ }
+
+ // Setup/verification flow (not pending)
+ $username = $_SESSION['username'] ?? '';
+ if (!$username) {
+ http_response_code(400);
+ echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
+ exit;
+ }
+
+ $totpSecret = userModel::getTOTPSecret($username);
+ if (!$totpSecret) {
+ http_response_code(500);
+ echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
+ exit;
+ }
+
+ if (!$tfa->verifyCode($totpSecret, $code)) {
+ $_SESSION['totp_failures']++;
+ http_response_code(400);
+ echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
+ exit;
+ }
+
+ // Successful setup/verification
+ unset($_SESSION['totp_failures']);
+ echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
+ }
}
diff --git a/start.sh b/start.sh
index b2fe67f..56a7934 100644
--- a/start.sh
+++ b/start.sh
@@ -25,6 +25,10 @@ mkdir -p /var/www/metadata/log
chown www-data:www-data /var/www/metadata/log
chmod 775 /var/www/metadata/log
+mkdir -p /var/www/sessions
+chown www-data:www-data /var/www/sessions
+chmod 700 /var/www/sessions
+
# 2.2) Prepare other dynamic dirs
for d in uploads users metadata; do
tgt="/var/www/${d}"