New fetchWithCsrf with fallback for session change. start.sh session directory added.

This commit is contained in:
Ryan
2025-04-23 09:53:21 -04:00
committed by GitHub
parent 89f124250c
commit 06b3f28df0
11 changed files with 492 additions and 296 deletions

View File

@@ -1,6 +1,6 @@
# Changelog # Changelog
## Changes 4/23/2025 ## Changes 4/23/2025 1.2.4
**AuthModel** **AuthModel**
@@ -16,8 +16,19 @@
- Repopulates `$_SESSION['authenticated']`, `username`, `isAdmin`, `folderOnly`, `readOnly`, `disableUpload` from payload - Repopulates `$_SESSION['authenticated']`, `username`, `isAdmin`, `folderOnly`, `readOnly`, `disableUpload` from payload
- Regenerates session ID and CSRF token, then immediately returns JSON and exits - Regenerates session ID and CSRF token, then immediately returns JSON and exits
- **Updated** `userController.php` - **Updated** `userController.php`
- Fixed totp isAdmin when session is missing but `remember_me_token` cookie present - Fixed totp isAdmin when session is missing but `remember_me_token` cookie present
- **loadCsrfToken()**
- Now reads `X-CSRF-Token` response header first, falls back to JSON `csrf_token` if header absent
- Updates `window.csrfToken`, `window.SHARE_URL`, and `<meta>` tags with the new values
- **fetchWithCsrf(url, options)**
- Sends `credentials: 'include'` and current `X-CSRF-Token` on every request
- Handles “soft-failure” JSON (`{ csrf_expired: true, csrf_token }`): updates token and retries once without a 403 in DevTools
- On HTTP 403 fallback: reads new token from header or `/api/auth/token.php`, updates token, and retries once
- **start.sh**
- Session directory setup
## Changes 4/22/2025 v1.2.3 ## Changes 4/22/2025 v1.2.3

View File

@@ -41,6 +41,7 @@ upload_tmp_dir=/tmp
session.gc_maxlifetime=1440 session.gc_maxlifetime=1440
session.gc_probability=1 session.gc_probability=1
session.gc_divisor=100 session.gc_divisor=100
session.save_path = "/var/www/sessions"
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Error Handling / Logging ; Error Handling / Logging

View File

@@ -44,6 +44,55 @@ function showToast(msgKey) {
} }
window.showToast = showToast; window.showToast = showToast;
const originalFetch = window.fetch;
/*
* @param {string} url
* @param {object} options
* @returns {Promise<Response>}
*/
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) Softfailure 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 // wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows
function openTOTPLoginModal() { function openTOTPLoginModal() {
originalOpenTOTPLoginModal(); originalOpenTOTPLoginModal();
@@ -236,6 +285,10 @@ function checkAuthentication(showLoginToast = true) {
if (typeof data.totp_enabled !== "undefined") { if (typeof data.totp_enabled !== "undefined") {
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false"); 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); updateAuthenticatedUI(data);
return data; return data;
} else { } else {
@@ -277,11 +330,11 @@ async function submitLogin(data) {
try { try {
const perm = await sendRequest("/api/getUserPermissions.php", "GET"); const perm = await sendRequest("/api/getUserPermissions.php", "GET");
if (perm && typeof perm === "object") { if (perm && typeof perm === "object") {
localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false"); localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", perm.readOnly ? "true" : "false"); localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
localStorage.setItem("disableUpload",perm.disableUpload? "true" : "false"); localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false");
} }
} catch {} } catch { }
return window.location.reload(); return window.location.reload();
} }
@@ -406,10 +459,10 @@ function initAuth() {
} }
let url = "/api/addUser.php"; let url = "/api/addUser.php";
if (window.setupMode) url += "?setup=1"; if (window.setupMode) url += "?setup=1";
fetch(url, { fetchWithCsrf(url, {
method: "POST", method: "POST",
credentials: "include", 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 }) body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
}) })
.then(response => response.json()) .then(response => response.json())
@@ -439,10 +492,10 @@ function initAuth() {
} }
const confirmed = await showCustomConfirmModal("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; if (!confirmed) return;
fetch("/api/removeUser.php", { fetchWithCsrf("/api/removeUser.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: usernameToRemove }) body: JSON.stringify({ username: usernameToRemove })
}) })
.then(response => response.json()) .then(response => response.json())
@@ -478,10 +531,10 @@ function initAuth() {
return; return;
} }
const data = { oldPassword, newPassword, confirmPassword }; const data = { oldPassword, newPassword, confirmPassword };
fetch("/api/changePassword.php", { fetchWithCsrf("/api/changePassword.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data) body: JSON.stringify(data)
}) })
.then(response => response.json()) .then(response => response.json())

View File

@@ -3,7 +3,7 @@ import { sendRequest } from './networkUtils.js';
import { t, applyTranslations, setLocale } from './i18n.js'; import { t, applyTranslations, setLocale } from './i18n.js';
import { loadAdminConfigFunc } from './auth.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")} <small style="font-size: 12px; color: gray;">${version}</small>`; const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`;
let lastLoginData = null; let lastLoginData = null;

View File

@@ -4,6 +4,8 @@ import { loadFileList } from './fileListView.js';
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js'; import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
import { openFolderShareModal } from './folderShareModal.js'; import { openFolderShareModal } from './folderShareModal.js';
import { fetchWithCsrf } from './auth.js';
import { loadCsrfToken } from './main.js';
/* ---------------------- /* ----------------------
Helper Functions (Data/State) Helper Functions (Data/State)
@@ -337,7 +339,7 @@ export async function loadFolderTree(selectedFolder) {
try { try {
// Check if the user has folder-only permission. // Check if the user has folder-only permission.
await checkUserFolderPermission(); await checkUserFolderPermission();
// Determine effective root folder. // Determine effective root folder.
const username = localStorage.getItem("username") || "root"; const username = localStorage.getItem("username") || "root";
let effectiveRoot = "root"; let effectiveRoot = "root";
@@ -351,14 +353,14 @@ export async function loadFolderTree(selectedFolder) {
} else { } else {
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root"; window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
} }
// Build fetch URL. // Build fetch URL.
let fetchUrl = '/api/folder/getFolderList.php'; let fetchUrl = '/api/folder/getFolderList.php';
if (window.userFolderOnly) { if (window.userFolderOnly) {
fetchUrl += '?restricted=1'; fetchUrl += '?restricted=1';
} }
console.log("Fetching folder list from:", fetchUrl); console.log("Fetching folder list from:", fetchUrl);
// Fetch folder list from the server. // Fetch folder list from the server.
const response = await fetch(fetchUrl); const response = await fetch(fetchUrl);
if (response.status === 401) { if (response.status === 401) {
@@ -375,10 +377,10 @@ export async function loadFolderTree(selectedFolder) {
} else if (Array.isArray(folderData)) { } else if (Array.isArray(folderData)) {
folders = folderData; folders = folderData;
} }
// Remove any global "root" entry. // Remove any global "root" entry.
folders = folders.filter(folder => folder.toLowerCase() !== "root"); folders = folders.filter(folder => folder.toLowerCase() !== "root");
// If restricted, filter folders: keep only those that start with effectiveRoot + "/" (do not include effectiveRoot itself). // If restricted, filter folders: keep only those that start with effectiveRoot + "/" (do not include effectiveRoot itself).
if (window.userFolderOnly && effectiveRoot !== "root") { if (window.userFolderOnly && effectiveRoot !== "root") {
folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/")); folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/"));
@@ -386,16 +388,16 @@ export async function loadFolderTree(selectedFolder) {
localStorage.setItem("lastOpenedFolder", effectiveRoot); localStorage.setItem("lastOpenedFolder", effectiveRoot);
window.currentFolder = effectiveRoot; window.currentFolder = effectiveRoot;
} }
localStorage.setItem("lastOpenedFolder", window.currentFolder); localStorage.setItem("lastOpenedFolder", window.currentFolder);
// Render the folder tree. // Render the folder tree.
const container = document.getElementById("folderTreeContainer"); const container = document.getElementById("folderTreeContainer");
if (!container) { if (!container) {
console.error("Folder tree container not found."); console.error("Folder tree container not found.");
return; return;
} }
let html = `<div id="rootRow" class="root-row"> let html = `<div id="rootRow" class="root-row">
<span class="folder-toggle" data-folder="${effectiveRoot}">[<span class="custom-dash">-</span>]</span> <span class="folder-toggle" data-folder="${effectiveRoot}">[<span class="custom-dash">-</span>]</span>
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">${effectiveLabel}</span> <span class="folder-option root-folder-option" data-folder="${effectiveRoot}">${effectiveLabel}</span>
@@ -405,35 +407,35 @@ export async function loadFolderTree(selectedFolder) {
html += renderFolderTree(tree, "", "block"); html += renderFolderTree(tree, "", "block");
} }
container.innerHTML = html; container.innerHTML = html;
// Attach drag/drop event listeners. // Attach drag/drop event listeners.
container.querySelectorAll(".folder-option").forEach(el => { container.querySelectorAll(".folder-option").forEach(el => {
el.addEventListener("dragover", folderDragOverHandler); el.addEventListener("dragover", folderDragOverHandler);
el.addEventListener("dragleave", folderDragLeaveHandler); el.addEventListener("dragleave", folderDragLeaveHandler);
el.addEventListener("drop", folderDropHandler); el.addEventListener("drop", folderDropHandler);
}); });
if (selectedFolder) { if (selectedFolder) {
window.currentFolder = selectedFolder; window.currentFolder = selectedFolder;
} }
localStorage.setItem("lastOpenedFolder", window.currentFolder); localStorage.setItem("lastOpenedFolder", window.currentFolder);
const titleEl = document.getElementById("fileListTitle"); const titleEl = document.getElementById("fileListTitle");
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(window.currentFolder) + ")"; titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(window.currentFolder) + ")";
setupBreadcrumbDelegation(); setupBreadcrumbDelegation();
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
const folderState = loadFolderTreeState(); const folderState = loadFolderTreeState();
if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") { if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") {
expandTreePath(window.currentFolder); expandTreePath(window.currentFolder);
} }
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`); const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`);
if (selectedEl) { if (selectedEl) {
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected")); container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
selectedEl.classList.add("selected"); selectedEl.classList.add("selected");
} }
container.querySelectorAll(".folder-option").forEach(el => { container.querySelectorAll(".folder-option").forEach(el => {
el.addEventListener("click", function (e) { el.addEventListener("click", function (e) {
e.stopPropagation(); e.stopPropagation();
@@ -448,7 +450,7 @@ export async function loadFolderTree(selectedFolder) {
loadFileList(selected); loadFileList(selected);
}); });
}); });
const rootToggle = container.querySelector("#rootRow .folder-toggle"); const rootToggle = container.querySelector("#rootRow .folder-toggle");
if (rootToggle) { if (rootToggle) {
rootToggle.addEventListener("click", function (e) { rootToggle.addEventListener("click", function (e) {
@@ -471,7 +473,7 @@ export async function loadFolderTree(selectedFolder) {
} }
}); });
} }
container.querySelectorAll(".folder-toggle").forEach(toggle => { container.querySelectorAll(".folder-toggle").forEach(toggle => {
toggle.addEventListener("click", function (e) { toggle.addEventListener("click", function (e) {
e.stopPropagation(); e.stopPropagation();
@@ -494,7 +496,7 @@ export async function loadFolderTree(selectedFolder) {
} }
}); });
}); });
} catch (error) { } catch (error) {
console.error("Error loading folder tree:", error); console.error("Error loading folder tree:", error);
} }
@@ -627,45 +629,53 @@ document.getElementById("cancelCreateFolder").addEventListener("click", function
document.getElementById("newFolderName").value = ""; document.getElementById("newFolderName").value = "";
}); });
attachEnterKeyListener("createFolderModal", "submitCreateFolder"); attachEnterKeyListener("createFolderModal", "submitCreateFolder");
document.getElementById("submitCreateFolder").addEventListener("click", function () { document.getElementById("submitCreateFolder").addEventListener("click", async () => {
const folderInput = document.getElementById("newFolderName").value.trim(); const folderInput = document.getElementById("newFolderName").value.trim();
if (!folderInput) { if (!folderInput) return showToast("Please enter a folder name.");
showToast("Please enter a folder name.");
return; 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; // 2) Call with fetchWithCsrf
if (selectedFolder && selectedFolder !== "root") { fetchWithCsrf("/api/folder/createFolder.php", {
fullFolderName = selectedFolder + "/" + folderInput;
}
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch("/api/folder/createFolder.php", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json", body: JSON.stringify({ folderName: folderInput, parent })
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({
folderName: folderInput,
parent: selectedFolder === "root" ? "" : selectedFolder
})
}) })
.then(response => response.json()) .then(async res => {
.then(data => { if (!res.ok) {
if (data.success) { // pull out a JSON error, or fallback to status text
showToast("Folder created successfully!"); let err;
window.currentFolder = fullFolderName; try {
localStorage.setItem("lastOpenedFolder", fullFolderName); const j = await res.json();
loadFolderList(fullFolderName); err = j.error || j.message || res.statusText;
} else { } catch {
showToast("Error: " + (data.error || "Could not create folder")); 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("createFolderModal").style.display = "none";
document.getElementById("newFolderName").value = ""; document.getElementById("newFolderName").value = "";
})
.catch(error => {
console.error("Error creating folder:", error);
document.getElementById("createFolderModal").style.display = "none";
}); });
}); });

View File

@@ -1,8 +1,10 @@
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js';
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js'; import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
import { loadFolderTree } from './folderManager.js';
import { initUpload } from './upload.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 { setupTrashRestoreDelete } from './trashRestoreDelete.js';
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js'; import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.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'; import { t, applyTranslations, setLocale } from './i18n.js';
// Remove the retry logic version and just use loadCsrfToken directly: // 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 <meta> 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 => { .then(response => {
if (!response.ok) { 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 => { .then(({ csrf_token, share_url }) => {
window.csrfToken = data.csrf_token; // Update globals
window.SHARE_URL = data.share_url; window.csrfToken = csrf_token;
window.SHARE_URL = 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);
let metaShare = document.querySelector('meta[name="share-url"]'); // Sync <meta name="csrf-token">
if (!metaShare) { let meta = document.querySelector('meta[name="csrf-token"]');
metaShare = document.createElement('meta'); if (!meta) {
metaShare.name = 'share-url'; meta = document.createElement('meta');
document.head.appendChild(metaShare); meta.name = 'csrf-token';
document.head.appendChild(meta);
} }
metaShare.setAttribute('content', data.share_url); meta.content = csrf_token;
return data; // Sync <meta name="share-url">
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 };
}); });
} }

View File

@@ -412,7 +412,12 @@ function initResumableUpload() {
forceChunkSize: true, forceChunkSize: true,
testChunks: false, testChunks: false,
throttleProgressCallbacks: 1, 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"); const fileInput = document.getElementById("file");
@@ -496,26 +501,40 @@ function initResumableUpload() {
}); });
resumableInstance.on("fileSuccess", function(file, message) { 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) Softfail 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) { if (li && li.progressBar) {
li.progressBar.style.width = "100%"; li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done"; li.progressBar.innerText = "Done";
// Hide pause/resume and remove buttons for successful files. // remove action buttons
const pauseResumeBtn = li.querySelector(".pause-resume-btn"); const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) { if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
pauseResumeBtn.style.display = "none";
}
const removeBtn = li.querySelector(".remove-file-btn"); const removeBtn = li.querySelector(".remove-file-btn");
if (removeBtn) { if (removeBtn) removeBtn.style.display = "none";
removeBtn.style.display = "none"; setTimeout(() => li.remove(), 5000);
}
// Schedule removal of the file entry after 5 seconds.
setTimeout(() => {
li.remove();
window.selectedFiles = window.selectedFiles.filter(f => f.uniqueIdentifier !== file.uniqueIdentifier);
updateFileInfoCount();
}, 5000);
} }
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
}); });
@@ -618,8 +637,25 @@ function submitFiles(allFiles) {
} catch (e) { } catch (e) {
jsonResponse = null; 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]; const li = progressElements[file.uploadIndex];
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) { if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
// real success
if (li) { if (li) {
li.progressBar.style.width = "100%"; li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done"; li.progressBar.innerText = "Done";
@@ -627,11 +663,14 @@ function submitFiles(allFiles) {
} }
uploadResults[file.uploadIndex] = true; uploadResults[file.uploadIndex] = true;
} else { } else {
// real failure
if (li) { if (li) {
li.progressBar.innerText = "Error"; li.progressBar.innerText = "Error";
} }
allSucceeded = false; allSucceeded = false;
} }
// ─── Only now count this chunk as finished ───────────────────
finishedCount++; finishedCount++;
if (finishedCount === allFiles.length) { if (finishedCount === allFiles.length) {
refreshFileList(allFiles, uploadResults, progressElements); refreshFileList(allFiles, uploadResults, progressElements);
@@ -665,6 +704,7 @@ function submitFiles(allFiles) {
}); });
xhr.open("POST", "/api/upload/upload.php", true); xhr.open("POST", "/api/upload/upload.php", true);
xhr.withCredentials = true;
xhr.setRequestHeader("X-CSRF-Token", window.csrfToken); xhr.setRequestHeader("X-CSRF-Token", window.csrfToken);
xhr.send(formData); xhr.send(formData);
}); });

View File

@@ -346,7 +346,9 @@ class AuthController
if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) { if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) {
$payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']); $payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']);
if ($payload) { if ($payload) {
$old = $_SESSION['csrf_token'] ?? bin2hex(random_bytes(32));
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['csrf_token'] = $old;
$_SESSION['authenticated'] = true; $_SESSION['authenticated'] = true;
$_SESSION['username'] = $payload['username']; $_SESSION['username'] = $payload['username'];
$_SESSION['isAdmin'] = !empty($payload['isAdmin']); $_SESSION['isAdmin'] = !empty($payload['isAdmin']);
@@ -354,7 +356,7 @@ class AuthController
$_SESSION['readOnly'] = $payload['readOnly'] ?? false; $_SESSION['readOnly'] = $payload['readOnly'] ?? false;
$_SESSION['disableUpload'] = $payload['disableUpload'] ?? false; $_SESSION['disableUpload'] = $payload['disableUpload'] ?? false;
// regenerate CSRF if you use one // regenerate CSRF if you use one
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// TOTP enabled? (same logic as below) // TOTP enabled? (same logic as below)
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
@@ -371,6 +373,7 @@ class AuthController
echo json_encode([ echo json_encode([
'authenticated' => true, 'authenticated' => true,
'csrf_token' => $_SESSION['csrf_token'],
'isAdmin' => $_SESSION['isAdmin'], 'isAdmin' => $_SESSION['isAdmin'],
'totp_enabled' => $totp, 'totp_enabled' => $totp,
'username' => $_SESSION['username'], 'username' => $_SESSION['username'],
@@ -446,10 +449,19 @@ class AuthController
*/ */
public function getToken(): void 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('Content-Type: application/json');
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
// 3) Return JSON payload
echo json_encode([ echo json_encode([
"csrf_token" => $_SESSION['csrf_token'], 'csrf_token' => $_SESSION['csrf_token'],
"share_url" => SHARE_URL 'share_url' => SHARE_URL
]); ]);
exit; exit;
} }

View File

@@ -72,34 +72,56 @@ class UploadController {
*/ */
public function handleUpload(): void { public function handleUpload(): void {
header('Content-Type: application/json'); header('Content-Type: application/json');
// CSRF Protection. //
// 1) CSRF pull from header or POST fields
//
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = $headersArr['x-csrf-token'] ?? ''; $received = '';
if (!isset($_SESSION['csrf_token']) || trim($receivedToken) !== $_SESSION['csrf_token']) { if (!empty($headersArr['x-csrf-token'])) {
http_response_code(403); $received = trim($headersArr['x-csrf-token']);
echo json_encode(["error" => "Invalid 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 doesnt 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; 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); http_response_code(401);
echo json_encode(["error" => "Unauthorized"]); echo json_encode(["error" => "Unauthorized"]);
exit; exit;
} }
// Check user permissions. $userPerms = loadUserPermissions($_SESSION['username']);
$username = $_SESSION['username'] ?? ''; if (!empty($userPerms['disableUpload'])) {
$userPermissions = loadUserPermissions($username);
if ($username && !empty($userPermissions['disableUpload'])) {
http_response_code(403); http_response_code(403);
echo json_encode(["error" => "Upload disabled for this user."]); echo json_encode(["error" => "Upload disabled for this user."]);
exit; exit;
} }
// Delegate to the model. //
// 3) Delegate the actual file handling
//
$result = UploadModel::handleUpload($_POST, $_FILES); $result = UploadModel::handleUpload($_POST, $_FILES);
// For chunked uploads, output JSON (e.g., "chunk uploaded" status). //
// 4) Respond
//
if (isset($result['error'])) { if (isset($result['error'])) {
http_response_code(400); http_response_code(400);
echo json_encode($result); echo json_encode($result);
@@ -109,8 +131,8 @@ class UploadController {
echo json_encode($result); echo json_encode($result);
exit; exit;
} }
// Otherwise, for full upload success, set a flash message and redirect. // fullupload redirect
$_SESSION['upload_message'] = "File uploaded successfully."; $_SESSION['upload_message'] = "File uploaded successfully.";
exit; exit;
} }

View File

@@ -87,63 +87,83 @@ class UserController
public function addUser() public function addUser()
{ {
// 1) Ensure JSON output and session
header('Content-Type: application/json'); 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. // 2) Determine setup mode (first-ever admin creation)
// Setup mode means the "setup" query parameter is passed $usersFile = USERS_DIR . USERS_FILE;
// and users.txt is missing, empty, or contains only whitespace. $isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1'); $setupMode = false;
if ($isSetup && (!file_exists($usersFile) || filesize($usersFile) == 0 || trim(file_get_contents($usersFile)) === '')) { if (
// Allow initial admin creation without session or CSRF checks. $isSetup && (! file_exists($usersFile)
|| filesize($usersFile) === 0
|| trim(file_get_contents($usersFile)) === ''
)
) {
$setupMode = true; $setupMode = true;
} else { } else {
$setupMode = false; // 3) In non-setup, enforce CSRF + auth checks
// In non-setup mode, perform CSRF token and authentication checks. $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); $receivedToken = trim($headersArr['x-csrf-token'] ?? '');
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) { // 3a) Soft-fail CSRF: on mismatch, regenerate and return new token
http_response_code(403); if ($receivedToken !== $_SESSION['csrf_token']) {
echo json_encode(["error" => "Invalid 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; exit;
} }
// 3b) Must be logged in as admin
if ( if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true || empty($_SESSION['authenticated'])
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true || $_SESSION['authenticated'] !== true
|| empty($_SESSION['isAdmin'])
|| $_SESSION['isAdmin'] !== true
) { ) {
echo json_encode(["error" => "Unauthorized"]); echo json_encode(["error" => "Unauthorized"]);
exit; exit;
} }
} }
// Get the JSON input data. // 4) Parse input
$data = json_decode(file_get_contents("php://input"), true); $data = json_decode(file_get_contents('php://input'), true) ?: [];
$newUsername = trim($data["username"] ?? ""); $newUsername = trim($data['username'] ?? '');
$newPassword = trim($data["password"] ?? ""); $newPassword = trim($data['password'] ?? '');
// In setup mode, force the new user to be an admin. // 5) Determine admin flag
if ($setupMode) { if ($setupMode) {
$isAdmin = "1"; $isAdmin = '1';
} else { } else {
$isAdmin = !empty($data["isAdmin"]) ? "1" : "0"; $isAdmin = !empty($data['isAdmin']) ? '1' : '0';
} }
// Validate that a username and password are provided. // 6) Validate fields
if (!$newUsername || !$newPassword) { if ($newUsername === '' || $newPassword === '') {
echo json_encode(["error" => "Username and password required"]); echo json_encode(["error" => "Username and password required"]);
exit; exit;
} }
// Validate username format.
if (!preg_match(REGEX_USER, $newUsername)) { 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; exit;
} }
// Delegate the business logic to the model. // 7) Delegate to model
$result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode); $result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
// 8) Return model result
echo json_encode($result); echo json_encode($result);
exit;
} }
/** /**
@@ -847,148 +867,151 @@ class UserController
* ) * )
*/ */
public function verifyTOTP() public function verifyTOTP()
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';"); header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
// Ratelimit // Ratelimit
if (!isset($_SESSION['totp_failures'])) { if (!isset($_SESSION['totp_failures'])) {
$_SESSION['totp_failures'] = 0; $_SESSION['totp_failures'] = 0;
} }
if ($_SESSION['totp_failures'] >= 5) { if ($_SESSION['totp_failures'] >= 5) {
http_response_code(429); http_response_code(429);
echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']); echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
exit; exit;
} }
// Must be authenticated OR pending login // Must be authenticated OR pending login
if (!((!empty($_SESSION['authenticated'])) || isset($_SESSION['pending_login_user']))) { if (!((!empty($_SESSION['authenticated'])) || isset($_SESSION['pending_login_user']))) {
http_response_code(403); http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']); echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
exit; exit;
} }
// CSRF check // CSRF check
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfHeader = $headersArr['x-csrf-token'] ?? ''; $csrfHeader = $headersArr['x-csrf-token'] ?? '';
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) { if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
http_response_code(403); http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']); echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
exit; exit;
} }
// Parse and validate input // Parse and validate input
$inputData = json_decode(file_get_contents("php://input"), true); $inputData = json_decode(file_get_contents("php://input"), true);
$code = trim($inputData['totp_code'] ?? ''); $code = trim($inputData['totp_code'] ?? '');
if (!preg_match('/^\d{6}$/', $code)) { if (!preg_match('/^\d{6}$/', $code)) {
http_response_code(400); http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']); echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
exit; exit;
} }
// TFA helper // TFA helper
$tfa = new \RobThree\Auth\TwoFactorAuth( $tfa = new \RobThree\Auth\TwoFactorAuth(
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1 'FileRise',
); 6,
30,
// Pendinglogin flow (first password step passed) \RobThree\Auth\Algorithm::Sha1
if (isset($_SESSION['pending_login_user'])) { );
$username = $_SESSION['pending_login_user'];
$pendingSecret = $_SESSION['pending_login_secret'] ?? null; // Pendinglogin flow (first password step passed)
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false; if (isset($_SESSION['pending_login_user'])) {
$username = $_SESSION['pending_login_user'];
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) { $pendingSecret = $_SESSION['pending_login_secret'] ?? null;
$_SESSION['totp_failures']++; $rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
exit; $_SESSION['totp_failures']++;
} http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
// === Issue “remember me” token if requested === exit;
if ($rememberMe) { }
$tokFile = USERS_DIR . 'persistent_tokens.json';
$token = bin2hex(random_bytes(32)); // === Issue “remember me” token if requested ===
$expiry = time() + 30 * 24 * 60 * 60; if ($rememberMe) {
$all = []; $tokFile = USERS_DIR . 'persistent_tokens.json';
$token = bin2hex(random_bytes(32));
if (file_exists($tokFile)) { $expiry = time() + 30 * 24 * 60 * 60;
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); $all = [];
$all = json_decode($dec, true) ?: [];
} if (file_exists($tokFile)) {
$isAdmin = ((int)userModel::getUserRole($username) === 1); $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
$all[$token] = [ $all = json_decode($dec, true) ?: [];
'username' => $username, }
'expiry' => $expiry, $isAdmin = ((int)userModel::getUserRole($username) === 1);
'isAdmin' => $isAdmin $all[$token] = [
]; 'username' => $username,
file_put_contents( 'expiry' => $expiry,
$tokFile, 'isAdmin' => $isAdmin
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']), ];
LOCK_EX file_put_contents(
); $tokFile,
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); LOCK_EX
);
// Persistent cookie
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true); $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
// Reissue PHP session cookie // Persistent cookie
setcookie( setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
session_name(),
session_id(), // Reissue PHP session cookie
$expiry, setcookie(
'/', session_name(),
'', session_id(),
$secure, $expiry,
true '/',
); '',
} $secure,
true
// Finalize login );
session_regenerate_id(true); }
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $username; // Finalize login
$_SESSION['isAdmin'] = $isAdmin; session_regenerate_id(true);
$_SESSION['folderOnly'] = loadUserPermissions($username); $_SESSION['authenticated'] = true;
$_SESSION['username'] = $username;
// Clean up $_SESSION['isAdmin'] = $isAdmin;
unset( $_SESSION['folderOnly'] = loadUserPermissions($username);
$_SESSION['pending_login_user'],
$_SESSION['pending_login_secret'], // Clean up
$_SESSION['pending_login_remember_me'], unset(
$_SESSION['totp_failures'] $_SESSION['pending_login_user'],
); $_SESSION['pending_login_secret'],
$_SESSION['pending_login_remember_me'],
echo json_encode(['status' => 'ok', 'message' => 'Login successful']); $_SESSION['totp_failures']
exit; );
}
echo json_encode(['status' => 'ok', 'message' => 'Login successful']);
// Setup/verification flow (not pending) exit;
$username = $_SESSION['username'] ?? ''; }
if (!$username) {
http_response_code(400); // Setup/verification flow (not pending)
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']); $username = $_SESSION['username'] ?? '';
exit; if (!$username) {
} http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
$totpSecret = userModel::getTOTPSecret($username); exit;
if (!$totpSecret) { }
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']); $totpSecret = userModel::getTOTPSecret($username);
exit; if (!$totpSecret) {
} http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
if (!$tfa->verifyCode($totpSecret, $code)) { exit;
$_SESSION['totp_failures']++; }
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); if (!$tfa->verifyCode($totpSecret, $code)) {
exit; $_SESSION['totp_failures']++;
} http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
// Successful setup/verification exit;
unset($_SESSION['totp_failures']); }
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
} // Successful setup/verification
unset($_SESSION['totp_failures']);
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
}
} }

View File

@@ -25,6 +25,10 @@ mkdir -p /var/www/metadata/log
chown www-data:www-data /var/www/metadata/log chown www-data:www-data /var/www/metadata/log
chmod 775 /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 # 2.2) Prepare other dynamic dirs
for d in uploads users metadata; do for d in uploads users metadata; do
tgt="/var/www/${d}" tgt="/var/www/${d}"