diff --git a/CHANGELOG.md b/CHANGELOG.md index f7844c9..babaa20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,102 @@ # Changelog +## Changes 10/15/2025 (v1.4.0) + +feat(permissions)!: granular ACL (bypassOwnership/canShare/canZip/viewOwnOnly), admin panel v1.4.0 UI, and broad hardening across controllers/models/frontend + +### Security / Hardening + +- Tightened ownership checks across file ops; introduced centralized permission helper to avoid falsey-permissions bugs. +- Consistent CSRF verification on mutating endpoints; stricter input validation using `REGEX_*` and `basename()` trims. +- Safer path handling & metadata reads; reduced noisy error surfaces; consistent HTTP codes (401/403/400/500). +- Adds defense-in-depth to reduce risk of unauthorized file manipulation. + +### Config (`config.php`) + +- Add optional defaults for new permissions (all optional): + - `DEFAULT_BYPASS_OWNERSHIP` (bool) + - `DEFAULT_CAN_SHARE` (bool) + - `DEFAULT_CAN_ZIP` (bool) + - `DEFAULT_VIEW_OWN_ONLY` (bool) +- Keep existing behavior unless explicitly enabled (bypassOwnership typically true for admins; configurable per user). + +### Controllers + +#### `FileController.php` + +- New lightweight `loadPerms($username)` helper that **always** returns an array; prevents type errors when permissions are missing. +- Ownership checks now respect: `isAdmin(...) || perms['bypassOwnership'] || DEFAULT_BYPASS_OWNERSHIP`. +- Gate sharing/zip operations by `perms['canShare']` / `perms['canZip']`. +- Implement `viewOwnOnly` filtering in `getFileList()` (supports both map and list shapes). +- Normalize and validate folder/file input; enforce folder-only scope for writes/moves/copies where applicable. +- Improve error handling: convert warnings/notices to exceptions within try/catch; consistent JSON error payloads. +- Add missing `require_once PROJECT_ROOT . '/src/models/UserModel.php'` to fix “Class userModel not found”. +- Download behavior: inline for images, attachment for others; owner/bypass logic applied. + +#### `FolderController.php` + +- `createShareFolderLink()` gated by `canShare`; validates duration (cap at 1y), folder names, password optional. +- (If present) folder share deletion/read endpoints wired to new permission model. + +#### `AdminController.php` + +- `getConfig()` remains admin-only; returns safe subset. (Non-admins now simply receive 403; client can ignore.) + +#### `UserController.php` + +- Plumbs new permission fields in get/set endpoints (`folderOnly`, `readOnly`, `disableUpload`, **`bypassOwnership`**, **`canShare`**, **`canZip`**, **`viewOwnOnly`**). +- Normalizes username keys and defaults to prevent undefined-index errors. + +### Models + +#### `FileModel.php` / `FolderModel.php` + +- Respect caller’s effective permissions (controllers pass-through); stricter input normalization. +- ZIP creation/extraction guarded via `canZip`; metadata updates consistent; safer temp paths. +- Improved return shapes and error messages (never return non-array on success paths). + +#### `AdminModel.php` + +- Reads/writes admin config with new `loginOptions` intact; never exposes sensitive OIDC secrets to the client layer. + +#### `UserModel.php` + +- Store/load the 4 new flags; helper ensures absent users/fields don’t break caller; returns normalized arrays. + +### Frontend + +#### `main.js` + +- Initialize after CSRF; keep dark-mode persistence, welcome toast, drag-over UX. +- Leaves `loadAdminConfigFunc()` call in place (non-admins may 403; harmless). + +#### `adminPanel.js` (v1.4.0) + +- New **User Permissions** UI with collapsible rows per user: + - Shows username; clicking expands a checkbox matrix. + - Permissions: `folderOnly`, `readOnly`, `disableUpload`, **`bypassOwnership`**, **`canShare`**, **`canZip`**, **`viewOwnOnly`**. +- **Manage Shared Links** section reads folder & file share metadata; delete buttons per token. +- Refined modal sizing & dark-mode styling; consistent toasts; unsaved-changes confirmation. +- Keeps 403 from `/api/admin/getConfig.php` for non-admins (acceptable; no UI break). + +### Breaking change + +- Non-admin users without `bypassOwnership` can no longer create/rename/move/copy/delete/share/zip files they don’t own. +- If legacy behavior depended on broad access, set `bypassOwnership` per user or use `DEFAULT_BYPASS_OWNERSHIP=true` in `config.php`. + +### Migration + +- Add the new flags to existing users in your permissions store (or rely on `config.php` defaults). +- Verify admin accounts have either `isAdmin` or `bypassOwnership`/`canShare`/`canZip` as desired. +- Optionally tune `DEFAULT_*` constants for instance-wide defaults. + +### Security + +- Hardened access controls for file operations based on an external security report. + Details are withheld temporarily to protect users; a full advisory will follow after wider adoption of the fix. + +--- + ## Changes 10/8/2025 (no new version) chore: set up CI, add compose, tighten ignores, refresh README @@ -195,7 +292,7 @@ No behavior change unless SCAN_ON_START=true. - **Folder strip in file list** - `loadFileList` now fetches sub-folders in parallel from `/api/folder/getFolderList.php`. - - Filters to only *direct* children of the current folder, hiding `profile_pics` and `trash`. + - Filters to only direct children of the current folder, hiding `profile_pics` and `trash`. - Injects a new `.folder-strip-container` just below the Files In above (summary + slider). - Clicking a folder in the strip updates: - the breadcrumb (via `updateBreadcrumbTitle`) @@ -243,7 +340,7 @@ No behavior change unless SCAN_ON_START=true. - Moved previously standalone header buttons into the dropdown menu: - **User Panel** opens the modal - - **Admin Panel** only shown when `data.isAdmin` *and* on `demo.filerise.net` + - **Admin Panel** only shown when `data.isAdmin` and on `demo.filerise.net` - **API Docs** calls `openApiModal()` - **Logout** calls `triggerLogout()` - Each menu item now has a matching Material icon (e.g. `person`, `admin_panel_settings`, `description`, `logout`). @@ -364,7 +461,7 @@ No behavior change unless SCAN_ON_START=true. - Removed the static `AUTH_HEADER` fallback; instead read the adminConfig.json at the end of the file and: - Overwrote `AUTH_BYPASS` with the `loginOptions.authBypass` setting from disk. - Defined `AUTH_HEADER` (normalized, e.g. `"X_REMOTE_USER"`) based on `loginOptions.authHeaderName`. -- Inserted a **proxy-only auto-login** block *before* the usual session/auth checks: +- Inserted a **proxy-only auto-login** block before the usual session/auth checks: If `AUTH_BYPASS` is true and the trusted header (`$_SERVER['HTTP_' . AUTH_HEADER]`) is present, bump the session, mark the user authenticated/admin, load their permissions, and skip straight to JSON output. - Relax filename validation regex to allow broader Unicode and special chars @@ -406,7 +503,7 @@ No behavior change unless SCAN_ON_START=true. - In the “not authenticated” branch, only shows the login form if `authBypass` is false. - No other core fetch/token logic changed; all existing flows remain intact. -### Security +### Security old - **Admin API**: `getConfig.php` now returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data. @@ -435,7 +532,7 @@ No behavior change unless SCAN_ON_START=true. - **Added** `addUserModal`, `removeUserModal` & `renameFileModal` modals to `style="display:none;"` -### `main.js` +**`main.js`** - **Extracted** `initializeApp()` helper to centralize post-auth startup (tag search, file list, drag-and-drop, folder tree, upload, trash/restore, admin config). - **Updated** DOMContentLoaded `checkAuthentication()` flow to call `initializeApp()` when already authenticated. diff --git a/SECURITY.md b/SECURITY.md index 5b748ec..8c66985 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,12 @@ ## Supported Versions -FileRise is actively maintained. Only supported versions will receive security updates. For details on which versions are currently supported, please see the [Release Notes](https://github.com/error311/FileRise/releases). +We provide security fixes for the latest minor release line. + +| Version | Supported | +|------------|-----------| +| v1.4.x | ✅ | +| < v1.4.0 | ❌ | ## Reporting a Vulnerability diff --git a/config/config.php b/config/config.php index 8dfbb58..d58191d 100644 --- a/config/config.php +++ b/config/config.php @@ -36,6 +36,13 @@ define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u'); date_default_timezone_set(TIMEZONE); +if (!defined('DEFAULT_BYPASS_OWNERSHIP')) define('DEFAULT_BYPASS_OWNERSHIP', false); +if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true); +if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true); +if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false); + + + // Encryption helpers function encryptData($data, $encryptionKey) { diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index 3d06105..226ae04 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -3,9 +3,15 @@ import { loadAdminConfigFunc } from './auth.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { sendRequest } from './networkUtils.js'; -const version = "v1.3.15"; +const version = "v1.4.0"; const adminTitle = `${t("admin_panel")} ${version}`; +// Translate with fallback: if t(key) just echos the key, use a readable string. +const tf = (key, fallback) => { + const v = t(key); + return (v && v !== key) ? v : fallback; +}; + // ————— Inject updated styles ————— (function () { if (document.getElementById('adminPanelStyles')) return; @@ -493,21 +499,21 @@ export function openAdminPanel() { } function handleSave() { - const dFL = document.getElementById("disableFormLogin").checked; - const dBA = document.getElementById("disableBasicAuth").checked; - const dOIDC = document.getElementById("disableOIDCLogin").checked; - const aBypass= document.getElementById("authBypass").checked; - const aHeader= document.getElementById("authHeaderName").value.trim() || "X-Remote-User"; - const eWD = document.getElementById("enableWebDAV").checked; - const sMax = parseInt(document.getElementById("sharedMaxUploadSize").value, 10) || 0; - const nHT = document.getElementById("headerTitle").value.trim(); - const nOIDC = { + const dFL = document.getElementById("disableFormLogin").checked; + const dBA = document.getElementById("disableBasicAuth").checked; + const dOIDC = document.getElementById("disableOIDCLogin").checked; + const aBypass = document.getElementById("authBypass").checked; + const aHeader = document.getElementById("authHeaderName").value.trim() || "X-Remote-User"; + const eWD = document.getElementById("enableWebDAV").checked; + const sMax = parseInt(document.getElementById("sharedMaxUploadSize").value, 10) || 0; + const nHT = document.getElementById("headerTitle").value.trim(); + const nOIDC = { providerUrl: document.getElementById("oidcProviderUrl").value.trim(), - clientId: document.getElementById("oidcClientId").value.trim(), - clientSecret:document.getElementById("oidcClientSecret").value.trim(), + clientId: document.getElementById("oidcClientId").value.trim(), + clientSecret: document.getElementById("oidcClientSecret").value.trim(), redirectUri: document.getElementById("oidcRedirectUri").value.trim() }; - const gURL = document.getElementById("globalOtpauthUrl").value.trim(); + const gURL = document.getElementById("globalOtpauthUrl").value.trim(); if ([dFL, dBA, dOIDC].filter(x => x).length === 3) { showToast(t("at_least_one_login_method")); @@ -521,25 +527,25 @@ function handleSave() { disableFormLogin: dFL, disableBasicAuth: dBA, disableOIDCLogin: dOIDC, - authBypass: aBypass, - authHeaderName: aHeader + authBypass: aBypass, + authHeaderName: aHeader }, - enableWebDAV: eWD, - sharedMaxUploadSize: sMax, - globalOtpauthUrl: gURL + enableWebDAV: eWD, + sharedMaxUploadSize: sMax, + globalOtpauthUrl: gURL }, { "X-CSRF-Token": window.csrfToken }) - .then(res => { - if (res.success) { - showToast(t("settings_updated_successfully"), "success"); - captureInitialAdminConfig(); - closeAdminPanel(); - loadAdminConfigFunc(); - } else { - showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error"); - } - }).catch(() => {/*noop*/}); + .then(res => { + if (res.success) { + showToast(t("settings_updated_successfully"), "success"); + captureInitialAdminConfig(); + closeAdminPanel(); + loadAdminConfigFunc(); + } else { + showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error"); + } + }).catch(() => {/*noop*/ }); } export async function closeAdminPanel() { @@ -605,15 +611,16 @@ export function openUserPermissionsModal() { const rows = userPermissionsModal.querySelectorAll(".user-permission-row"); const permissionsData = []; rows.forEach(row => { - const username = row.getAttribute("data-username"); - const folderOnlyCheckbox = row.querySelector("input[data-permission='folderOnly']"); - const readOnlyCheckbox = row.querySelector("input[data-permission='readOnly']"); - const disableUploadCheckbox = row.querySelector("input[data-permission='disableUpload']"); + const g = k => row.querySelector(`input[data-permission='${k}']`)?.checked ?? false; permissionsData.push({ - username, - folderOnly: folderOnlyCheckbox.checked, - readOnly: readOnlyCheckbox.checked, - disableUpload: disableUploadCheckbox.checked + username: row.getAttribute("data-username"), + folderOnly: g("folderOnly"), + readOnly: g("readOnly"), + disableUpload: g("disableUpload"), + bypassOwnership: g("bypassOwnership"), + canShare: g("canShare"), + canZip: g("canZip"), + viewOwnOnly: g("viewOwnOnly"), }); }); // Send the permissionsData to the server. @@ -664,38 +671,79 @@ function loadUserPermissionsList() { folderOnly: false, readOnly: false, disableUpload: false, + bypassOwnership: false, + canShare: false, + canZip: false, + viewOwnOnly: false, }; // Normalize the username key to match server storage (e.g., lowercase) const usernameKey = user.username.toLowerCase(); + + const toBool = v => v === true || v === 1 || v === "1"; const userPerm = (permissionsData && typeof permissionsData === "object" && (usernameKey in permissionsData)) ? permissionsData[usernameKey] : defaultPerm; - // Create a row for the user. - const row = document.createElement("div"); - row.classList.add("user-permission-row"); - row.setAttribute("data-username", user.username); - row.style.padding = "10px 0"; - row.innerHTML = ` -
${user.username}
-
- - - -
-
- `; + + // Create a row for the user (collapsed by default) +const row = document.createElement("div"); +row.classList.add("user-permission-row"); +row.setAttribute("data-username", user.username); +row.style.padding = "6px 0"; + +// helper for checkbox checked state +const checked = key => (userPerm && userPerm[key]) ? "checked" : ""; + +// header + caret +row.innerHTML = ` + + + + +
+`; + +// toggle open/closed on click + Enter/Space +const header = row.querySelector(".user-perm-header"); +const details = row.querySelector(".user-perm-details"); +const caret = row.querySelector(".perm-caret"); + +function toggleOpen() { + const willShow = details.style.display === "none"; + details.style.display = willShow ? "grid" : "none"; + header.setAttribute("aria-expanded", willShow ? "true" : "false"); + caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)"; +} + +header.addEventListener("click", toggleOpen); +header.addEventListener("keydown", e => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); } +}); + +listContainer.appendChild(row); listContainer.appendChild(row); }); }); diff --git a/public/js/fileListView.js b/public/js/fileListView.js index 378358a..3d49079 100644 --- a/public/js/fileListView.js +++ b/public/js/fileListView.js @@ -55,6 +55,18 @@ window.advancedSearchEnabled = false; * --- Helper Functions --- */ +// Safely parse JSON; if server returned HTML/text, throw it as a readable error. +async function safeJson(res) { + const text = await res.text(); + try { + return JSON.parse(text); + } catch { + // Common cases: PHP notice/HTML, "Access forbidden.", etc. + const msg = (text || '').toString().trim(); + throw new Error(msg || `Unexpected ${res.status} ${res.statusText} from ${res.url || 'request'}`); + } +} + /** * Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes. */ @@ -219,8 +231,14 @@ export async function loadFileList(folderParam) { try { // Kick off both in parallel, but we'll render as soon as FILES are ready - const filesPromise = fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`); - const foldersPromise = fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`); + const filesPromise = fetch( + `/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`, + { credentials: 'include' } + ); + const foldersPromise = fetch( + `/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`, + { credentials: 'include' } + ); // ----- FILES FIRST ----- const filesRes = await filesPromise; @@ -230,7 +248,10 @@ export async function loadFileList(folderParam) { throw new Error("Unauthorized"); } - const data = await filesRes.json(); + const data = await safeJson(filesRes); + if (data.error) { + throw new Error(typeof data.error === 'string' ? data.error : 'Server returned an error.'); + } // If another loadFileList ran after this one, bail before touching the DOM if (reqId !== __fileListReqSeq) return []; @@ -403,7 +424,7 @@ export async function loadFileList(folderParam) { // ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) ----- try { const foldersRes = await foldersPromise; - const folderRaw = await foldersRes.json(); + const folderRaw = await safeJson(foldersRes).catch(() => []); // don't block file render on folder strip issues if (reqId !== __fileListReqSeq) return data.files; // --- build ONLY the *direct* children of current folder --- diff --git a/public/js/main.js b/public/js/main.js index db14803..e18ef78 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -2,8 +2,6 @@ import { sendRequest } from './networkUtils.js'; import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js'; import { initUpload } from './upload.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'; @@ -14,14 +12,60 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload } import { editFile, saveFile } from './fileEditor.js'; import { t, applyTranslations, setLocale } from './i18n.js'; +/* ========================= + CSRF HOTFIX UTILITIES + ========================= */ +const _nativeFetch = window.fetch; // keep the real fetch + +function setCsrfToken(token) { + if (!token) return; + window.csrfToken = token; + localStorage.setItem('csrf', token); + + // meta tag for easy access in other places + let meta = document.querySelector('meta[name="csrf-token"]'); + if (!meta) { + meta = document.createElement('meta'); + meta.name = 'csrf-token'; + document.head.appendChild(meta); + } + meta.content = token; +} +function getCsrfToken() { + return window.csrfToken || localStorage.getItem('csrf') || ''; +} + +// Seed CSRF from storage ASAP (before any requests) +setCsrfToken(getCsrfToken()); + +// Wrap the existing fetchWithCsrf so we also capture rotated tokens from headers. +async function fetchWithCsrfAndRefresh(input, init = {}) { + const res = await fetchWithCsrf(input, init); + try { + const rotated = res.headers?.get('X-CSRF-Token'); + if (rotated) setCsrfToken(rotated); + } catch { /* ignore */ } + return res; +} + +// Replace global fetch with the wrapped version so *all* callers benefit. +window.fetch = fetchWithCsrfAndRefresh; + +/* ========================= + APP INIT + ========================= */ + export function initializeApp() { const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10); document.documentElement.style.setProperty('--file-row-height', saved + 'px'); + window.currentFolder = "root"; - initTagSearch(); - loadFileList(window.currentFolder); const stored = localStorage.getItem('showFoldersInList'); window.showFoldersInList = stored === null ? true : stored === 'true'; + + initTagSearch(); + loadFileList(window.currentFolder); + const fileListArea = document.getElementById('fileListContainer'); const uploadArea = document.getElementById('uploadDropArea'); if (fileListArea && uploadArea) { @@ -35,7 +79,6 @@ export function initializeApp() { fileListArea.addEventListener('drop', e => { e.preventDefault(); fileListArea.classList.remove('drop-hover'); - // re-dispatch the same drop into the real upload card uploadArea.dispatchEvent(new DragEvent('drop', { dataTransfer: e.dataTransfer, bubbles: true, @@ -63,27 +106,36 @@ export function initializeApp() { } } +/** + * Bootstrap/refresh CSRF from the server. + * Uses the *native* fetch to avoid any wrapper loops and to work even if we don't + * yet have a token. Also accepts a rotated token from the response header. + */ export function loadCsrfToken() { - return fetchWithCsrf('/api/auth/token.php', { method: 'GET' }) - .then(res => { - if (!res.ok) throw new Error(`Token fetch failed with status ${res.status}`); - return res.json(); - }) - .then(({ csrf_token, share_url }) => { - window.csrfToken = csrf_token; + return _nativeFetch('/api/auth/token.php', { method: 'GET', credentials: 'include' }) + .then(async res => { + // header-based rotation + const hdr = res.headers.get('X-CSRF-Token'); + if (hdr) setCsrfToken(hdr); - // update CSRF meta - let meta = document.querySelector('meta[name="csrf-token"]') || - Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'csrf-token' }); - meta.content = csrf_token; + // body (if provided) + let body = {}; + try { body = await res.json(); } catch { /* token endpoint may return empty */ } - // force share_url to match wherever we're browsing + const token = body.csrf_token || getCsrfToken(); + setCsrfToken(token); + + // share-url meta should reflect the actual origin const actualShare = window.location.origin; - let shareMeta = document.querySelector('meta[name="share-url"]') || - Object.assign(document.head.appendChild(document.createElement('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 = actualShare; - return { csrf_token, share_url: actualShare }; + return { csrf_token: token, share_url: actualShare }; }); } @@ -95,16 +147,15 @@ if (params.get('logout') === '1') { } export function triggerLogout() { - fetch("/api/auth/logout.php", { + _nativeFetch("/api/auth/logout.php", { method: "POST", credentials: "include", - headers: { "X-CSRF-Token": window.csrfToken } + headers: { "X-CSRF-Token": getCsrfToken() } }) .then(() => window.location.reload(true)) .catch(() => { }); } - // Expose functions for inline handlers. window.sendRequest = sendRequest; window.toggleVisibility = toggleVisibility; @@ -119,105 +170,79 @@ window.openDownloadModal = openDownloadModal; window.currentFolder = "root"; document.addEventListener("DOMContentLoaded", function () { + loadAdminConfigFunc(); - loadAdminConfigFunc(); // Then fetch the latest config and update. - // Retrieve the saved language from localStorage; default to "en" + // i18n const savedLanguage = localStorage.getItem("language") || "en"; - // Set the locale based on the saved language setLocale(savedLanguage); - // Apply the translations to update the UI applyTranslations(); - // First, load the CSRF token (with retry). - loadCsrfToken().then(() => { - // Once CSRF token is loaded, initialize authentication. - initAuth(); - // Continue with initializations that rely on a valid CSRF token: - checkAuthentication().then(authenticated => { - if (authenticated) { - const overlay = document.getElementById('loadingOverlay'); - if (overlay) overlay.remove(); - initializeApp(); - } - }); + // 1) Get/refresh CSRF first + loadCsrfToken() + .then(() => { + // 2) Auth boot + initAuth(); - // Other DOM initialization that can happen after CSRF is ready. - const newPasswordInput = document.getElementById("newPassword"); - if (newPasswordInput) { - newPasswordInput.addEventListener("input", function () { - console.log("newPassword input event:", this.value); + // 3) If authenticated, start app + checkAuthentication().then(authenticated => { + if (authenticated) { + const overlay = document.getElementById('loadingOverlay'); + if (overlay) overlay.remove(); + initializeApp(); + } }); - } else { - console.error("newPassword input not found!"); - } - // --- Dark Mode Persistence --- - const darkModeToggle = document.getElementById("darkModeToggle"); - const darkModeIcon = document.getElementById("darkModeIcon"); + // --- Dark Mode Persistence --- + const darkModeToggle = document.getElementById("darkModeToggle"); + const darkModeIcon = document.getElementById("darkModeIcon"); - if (darkModeToggle && darkModeIcon) { - // 1) Load stored preference (or null) - let stored = localStorage.getItem("darkMode"); - const hasStored = stored !== null; + if (darkModeToggle && darkModeIcon) { + let stored = localStorage.getItem("darkMode"); + const hasStored = stored !== null; - // 2) Determine initial mode - const isDark = hasStored - ? (stored === "true") - : (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches); + const isDark = hasStored + ? (stored === "true") + : (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches); - document.body.classList.toggle("dark-mode", isDark); - darkModeToggle.classList.toggle("active", isDark); + document.body.classList.toggle("dark-mode", isDark); + darkModeToggle.classList.toggle("active", isDark); - // 3) Helper to update icon & aria-label - function updateIcon() { - const dark = document.body.classList.contains("dark-mode"); - darkModeIcon.textContent = dark ? "light_mode" : "dark_mode"; - darkModeToggle.setAttribute( - "aria-label", - dark ? t("light_mode") : t("dark_mode") - ); - darkModeToggle.setAttribute( - "title", - dark - ? t("switch_to_light_mode") - : t("switch_to_dark_mode") - ); - } - - updateIcon(); - - // 4) Click handler: always override and store preference - darkModeToggle.addEventListener("click", () => { - const nowDark = document.body.classList.toggle("dark-mode"); - localStorage.setItem("darkMode", nowDark ? "true" : "false"); + function updateIcon() { + const dark = document.body.classList.contains("dark-mode"); + darkModeIcon.textContent = dark ? "light_mode" : "dark_mode"; + darkModeToggle.setAttribute("aria-label", dark ? t("light_mode") : t("dark_mode")); + darkModeToggle.setAttribute("title", dark ? t("switch_to_light_mode") : t("switch_to_dark_mode")); + } updateIcon(); - }); - // 5) OS‐level change: only if no stored pref at load - if (!hasStored && window.matchMedia) { - window - .matchMedia("(prefers-color-scheme: dark)") - .addEventListener("change", e => { + darkModeToggle.addEventListener("click", () => { + const nowDark = document.body.classList.toggle("dark-mode"); + localStorage.setItem("darkMode", nowDark ? "true" : "false"); + updateIcon(); + }); + + if (!hasStored && window.matchMedia) { + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => { document.body.classList.toggle("dark-mode", e.matches); updateIcon(); }); + } } - } - // --- End Dark Mode Persistence --- + // --- End Dark Mode Persistence --- - const message = sessionStorage.getItem("welcomeMessage"); - if (message) { - showToast(message); - sessionStorage.removeItem("welcomeMessage"); - } - }).catch(error => { - console.error("Initialization halted due to CSRF token load failure.", error); - }); + const message = sessionStorage.getItem("welcomeMessage"); + if (message) { + showToast(message); + sessionStorage.removeItem("welcomeMessage"); + } + }) + .catch(error => { + console.error("Initialization halted due to CSRF token load failure.", error); + }); // --- Auto-scroll During Drag --- - const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling - const SCROLL_SPEED = 20; // pixels to scroll per event - + const SCROLL_THRESHOLD = 50; + const SCROLL_SPEED = 20; document.addEventListener("dragover", function (e) { if (e.clientY < SCROLL_THRESHOLD) { window.scrollBy(0, -SCROLL_SPEED); diff --git a/src/controllers/AdminController.php b/src/controllers/AdminController.php index 855ad1d..759afbd 100644 --- a/src/controllers/AdminController.php +++ b/src/controllers/AdminController.php @@ -53,6 +53,17 @@ class AdminController public function getConfig(): void { header('Content-Type: application/json'); + + // Require authenticated admin to read config (prevents information disclosure) + if ( + empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true || + empty($_SESSION['isAdmin']) + ) { + http_response_code(403); + echo json_encode(['error' => 'Unauthorized access.']); + exit; + } + $config = AdminModel::getConfig(); if (isset($config['error'])) { http_response_code(500); @@ -62,14 +73,14 @@ class AdminController // Build a safe subset for the front-end $safe = [ - 'header_title' => $config['header_title'], - 'loginOptions' => $config['loginOptions'], - 'globalOtpauthUrl' => $config['globalOtpauthUrl'], - 'enableWebDAV' => $config['enableWebDAV'], - 'sharedMaxUploadSize' => $config['sharedMaxUploadSize'], + 'header_title' => $config['header_title'] ?? '', + 'loginOptions' => $config['loginOptions'] ?? [], + 'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '', + 'enableWebDAV' => $config['enableWebDAV'] ?? false, + 'sharedMaxUploadSize' => $config['sharedMaxUploadSize'] ?? 0, 'oidc' => [ - 'providerUrl' => $config['oidc']['providerUrl'], - 'redirectUri' => $config['oidc']['redirectUri'], + 'providerUrl' => $config['oidc']['providerUrl'] ?? '', + 'redirectUri' => $config['oidc']['redirectUri'] ?? '', // clientSecret and clientId never exposed here ], ]; @@ -137,106 +148,186 @@ class AdminController * @return void Outputs a JSON response indicating success or failure. */ public function updateConfig(): void -{ - header('Content-Type: application/json'); + { + header('Content-Type: application/json'); - // —– auth & CSRF checks —– - if ( - !isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true || - !isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin'] - ) { - http_response_code(403); - echo json_encode(['error' => 'Unauthorized access.']); - exit; - } - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $receivedToken = trim($headersArr['x-csrf-token'] ?? ($_POST['csrfToken'] ?? '')); - if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) { - http_response_code(403); - echo json_encode(['error' => 'Invalid CSRF token.']); - exit; - } - - // —– fetch payload —– - $data = json_decode(file_get_contents('php://input'), true); - if (!is_array($data)) { - http_response_code(400); - echo json_encode(['error' => 'Invalid input.']); - exit; - } - - // —– load existing on-disk config —– - $existing = AdminModel::getConfig(); - - // —– start merge with existing as base —– - $merged = $existing; - - // header_title - if (array_key_exists('header_title', $data)) { - $merged['header_title'] = trim($data['header_title']); - } - - // loginOptions: inherit existing then override if provided - $merged['loginOptions'] = $existing['loginOptions'] ?? [ - 'disableFormLogin' => false, - 'disableBasicAuth' => false, - 'disableOIDCLogin'=> true, - 'authBypass' => false, - 'authHeaderName' => 'X-Remote-User' - ]; - foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) { - if (isset($data['loginOptions'][$flag])) { - $merged['loginOptions'][$flag] = filter_var( - $data['loginOptions'][$flag], - FILTER_VALIDATE_BOOLEAN - ); + // —– auth & CSRF checks —– + if ( + !isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true || + !isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin'] + ) { + http_response_code(403); + echo json_encode(['error' => 'Unauthorized access.']); + exit; } - } - if (isset($data['loginOptions']['authHeaderName'])) { - $hdr = trim($data['loginOptions']['authHeaderName']); - if ($hdr !== '') { - $merged['loginOptions']['authHeaderName'] = $hdr; + $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); + $receivedToken = trim($headersArr['x-csrf-token'] ?? ($_POST['csrfToken'] ?? '')); + if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) { + http_response_code(403); + echo json_encode(['error' => 'Invalid CSRF token.']); + exit; } - } - // globalOtpauthUrl - if (array_key_exists('globalOtpauthUrl', $data)) { - $merged['globalOtpauthUrl'] = trim($data['globalOtpauthUrl']); - } + // —– fetch payload —– + $data = json_decode(file_get_contents('php://input'), true); + if (!is_array($data)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid input.']); + exit; + } - // enableWebDAV - if (array_key_exists('enableWebDAV', $data)) { - $merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN); - } + // —– load existing on-disk config —– + $existing = AdminModel::getConfig(); + if (isset($existing['error'])) { + http_response_code(500); + echo json_encode(['error' => $existing['error']]); + exit; + } - // sharedMaxUploadSize - if (array_key_exists('sharedMaxUploadSize', $data)) { - $sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT); - if ($sms !== false) { + // —– start merge with existing as base —– + // Ensure minimal structure if the file was partially missing. + $merged = $existing + [ + 'header_title' => '', + 'loginOptions' => [ + 'disableFormLogin' => false, + 'disableBasicAuth' => false, + 'disableOIDCLogin' => true, + 'authBypass' => false, + 'authHeaderName' => 'X-Remote-User' + ], + 'globalOtpauthUrl' => '', + 'enableWebDAV' => false, + 'sharedMaxUploadSize' => 0, + 'oidc' => [ + 'providerUrl' => '', + 'clientId' => '', + 'clientSecret'=> '', + 'redirectUri' => '' + ], + ]; + + // header_title (cap length and strip control chars) + if (array_key_exists('header_title', $data)) { + $title = trim((string)$data['header_title']); + $title = preg_replace('/[\x00-\x1F\x7F]/', '', $title); + if (mb_strlen($title) > 100) { // hard cap + $title = mb_substr($title, 0, 100); + } + $merged['header_title'] = $title; + } + + // loginOptions: inherit existing then override if provided + foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) { + if (isset($data['loginOptions'][$flag])) { + $merged['loginOptions'][$flag] = filter_var( + $data['loginOptions'][$flag], + FILTER_VALIDATE_BOOLEAN + ); + } + } + if (isset($data['loginOptions']['authHeaderName'])) { + $hdr = trim((string)$data['loginOptions']['authHeaderName']); + // very restrictive header-name pattern: letters, numbers, dashes + if ($hdr !== '' && preg_match('/^[A-Za-z0-9\-]+$/', $hdr)) { + $merged['loginOptions']['authHeaderName'] = $hdr; + } else { + http_response_code(400); + echo json_encode(['error' => 'Invalid authHeaderName.']); + exit; + } + } + + // globalOtpauthUrl + if (array_key_exists('globalOtpauthUrl', $data)) { + $merged['globalOtpauthUrl'] = trim((string)$data['globalOtpauthUrl']); + } + + // enableWebDAV + if (array_key_exists('enableWebDAV', $data)) { + $merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN); + } + + // sharedMaxUploadSize + if (array_key_exists('sharedMaxUploadSize', $data)) { + $sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT); + if ($sms === false || $sms < 0) { + http_response_code(400); + echo json_encode(['error' => 'sharedMaxUploadSize must be a non-negative integer (bytes).']); + exit; + } + // Clamp to PHP limits to avoid confusing UX + $maxPost = self::iniToBytes(ini_get('post_max_size')); + $maxFile = self::iniToBytes(ini_get('upload_max_filesize')); + $phpCap = min($maxPost ?: PHP_INT_MAX, $maxFile ?: PHP_INT_MAX); + if ($phpCap !== PHP_INT_MAX && $sms > $phpCap) { + $sms = $phpCap; + } $merged['sharedMaxUploadSize'] = $sms; } - } - // oidc: only overwrite non-empty inputs - $merged['oidc'] = $existing['oidc'] ?? [ - 'providerUrl'=>'','clientId'=>'','clientSecret'=>'','redirectUri'=>'' - ]; - foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) { - if (!empty($data['oidc'][$f])) { - $val = trim($data['oidc'][$f]); - if ($f === 'providerUrl' || $f === 'redirectUri') { - $val = filter_var($val, FILTER_SANITIZE_URL); + // oidc: only overwrite non-empty inputs; validate when enabling OIDC + foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) { + if (!empty($data['oidc'][$f])) { + $val = trim((string)$data['oidc'][$f]); + if ($f === 'providerUrl' || $f === 'redirectUri') { + $val = filter_var($val, FILTER_SANITIZE_URL); + } + $merged['oidc'][$f] = $val; } - $merged['oidc'][$f] = $val; } + + // If OIDC login is enabled, ensure required fields are present and sane + $oidcEnabled = !empty($merged['loginOptions']['disableOIDCLogin']) ? false : true; + if ($oidcEnabled) { + $prov = $merged['oidc']['providerUrl'] ?? ''; + $rid = $merged['oidc']['redirectUri'] ?? ''; + $cid = $merged['oidc']['clientId'] ?? ''; + // clientSecret may be empty for some PKCE-only flows, but commonly needed for code flow. + if ($prov === '' || $rid === '' || $cid === '') { + http_response_code(400); + echo json_encode(['error' => 'OIDC is enabled but providerUrl, redirectUri, and clientId are required.']); + exit; + } + // Require https except for localhost development + $httpsOk = function(string $url): bool { + if ($url === '') return false; + $parts = parse_url($url); + if (!$parts || empty($parts['scheme'])) return false; + if ($parts['scheme'] === 'https') return true; + if ($parts['scheme'] === 'http' && (isset($parts['host']) && ($parts['host'] === 'localhost' || $parts['host'] === '127.0.0.1'))) { + return true; + } + return false; + }; + if (!$httpsOk($prov) || !$httpsOk($rid)) { + http_response_code(400); + echo json_encode(['error' => 'providerUrl and redirectUri must be https (or http on localhost)']); + exit; + } + } + + // —– persist merged config —– + $result = AdminModel::updateConfig($merged); + if (isset($result['error'])) { + http_response_code(500); + } + echo json_encode($result); + exit; } - // —– persist merged config —– - $result = AdminModel::updateConfig($merged); - if (isset($result['error'])) { - http_response_code(500); + /** Convert php.ini shorthand like "128M" to bytes */ + private static function iniToBytes($val) + { + if ($val === false || $val === null || $val === '') return 0; + $val = trim((string)$val); + $last = strtolower($val[strlen($val)-1]); + $num = (int)$val; + switch ($last) { + case 'g': $num *= 1024; + case 'm': $num *= 1024; + case 'k': $num *= 1024; + } + return $num; } - echo json_encode($result); - exit; } -} \ No newline at end of file +?> \ No newline at end of file diff --git a/src/controllers/FileController.php b/src/controllers/FileController.php index 6275396..ef38d00 100644 --- a/src/controllers/FileController.php +++ b/src/controllers/FileController.php @@ -3,9 +3,163 @@ require_once __DIR__ . '/../../config/config.php'; require_once PROJECT_ROOT . '/src/models/FileModel.php'; +require_once PROJECT_ROOT . '/src/models/UserModel.php'; + class FileController { + /* ========================= + * Permission helpers (fail-closed) + * ========================= */ + private function isAdmin(array $perms): bool { + // explicit flags in permissions blob + if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true; + + // session-based flags commonly set at login + if (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) return true; + + // sometimes apps store role in session + $role = $_SESSION['role'] ?? null; + if ($role === 'admin' || $role === '1' || $role === 1) return true; + + // definitive fallback: read users.txt role ("1" means admin) + $u = $_SESSION['username'] ?? ''; + if ($u) { + $roleStr = userModel::getUserRole($u); + if ($roleStr === '1') return true; + } + return false; + } + + private function isFolderOnly(array $perms): bool { + return !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']); + } + + private function getMetadataPath(string $folder): string { + $folder = trim($folder); + if ($folder === '' || strtolower($folder) === 'root') { + return META_DIR . 'root_metadata.json'; + } + return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json'; + } + + private function loadFolderMetadata(string $folder): array { + $meta = $this->getMetadataPath($folder); + if (file_exists($meta)) { + $data = json_decode(file_get_contents($meta), true); + if (is_array($data)) return $data; + } + return []; + } + + // Always return an array for user permissions. + private function loadPerms(string $username): array + { + try { + if (function_exists('loadUserPermissions')) { + $p = loadUserPermissions($username); + return is_array($p) ? $p : []; + } + if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) { + $all = userModel::getUserPermissions(); + if (is_array($all)) { + if (isset($all[$username])) return (array)$all[$username]; + $lk = strtolower($username); + if (isset($all[$lk])) return (array)$all[$lk]; + } + } + } catch (\Throwable $e) { /* ignore */ } + return []; + } + + /** Enforce that (a) folder-only users act only in their subtree, and + * (b) non-admins own all files in the provided list (metadata.uploader === $username). + * Returns an error string on violation, or null if ok. */ + private function enforceScopeAndOwnership(string $folder, array $files, string $username, array $userPermissions): ?string { + $ignoreOwnership = $this->isAdmin($userPermissions) + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + + // Folder-only users must stay in "" subtree + if ($this->isFolderOnly($userPermissions) && !$this->isAdmin($userPermissions)) { + $folder = trim($folder); + if ($folder !== '' && strtolower($folder) !== 'root') { + if ($folder !== $username && strpos($folder, $username . '/') !== 0) { + return "Forbidden: folder scope violation."; + } + } + } + + if ($ignoreOwnership) return null; + + $metadata = $this->loadFolderMetadata($folder); + foreach ($files as $f) { + $name = basename((string)$f); + if (!isset($metadata[$name]['uploader']) || strcasecmp($metadata[$name]['uploader'], $username) !== 0) { + return "Forbidden: you are not the owner of '{$name}'."; + } + } + return null; + } + + private function enforceFolderScope(string $folder, string $username, array $userPermissions): ?string { + if ($this->isAdmin($userPermissions)) return null; + if (!$this->isFolderOnly($userPermissions)) return null; + + $folder = trim($folder); + if ($folder !== '' && strtolower($folder) !== 'root') { + if ($folder !== $username && strpos($folder, $username . '/') !== 0) { + return "Forbidden: folder scope violation."; + } + } + return null; + } + + // --- JSON/session/error helpers (non-breaking additions) --- +private function _jsonStart(): void { + if (session_status() !== PHP_SESSION_ACTIVE) session_start(); + header('Content-Type: application/json; charset=utf-8'); + // Turn notices/warnings into exceptions so we can return JSON instead of HTML + set_error_handler(function ($severity, $message, $file, $line) { + if (!(error_reporting() & $severity)) return; // respect @-silence + throw new ErrorException($message, 0, $severity, $file, $line); + }); +} + +private function _jsonEnd(): void { + restore_error_handler(); +} + +private function _jsonOut(array $payload, int $status = 200): void { + http_response_code($status); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} + +private function _checkCsrf(): bool { + $headersArr = function_exists('getallheaders') + ? array_change_key_case(getallheaders(), CASE_LOWER) + : []; + $receivedToken = $headersArr['x-csrf-token'] ?? ''; + if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) { + $this->_jsonOut(['error' => 'Invalid CSRF token'], 403); + return false; + } + return true; +} + +private function _requireAuth(): bool { + if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + $this->_jsonOut(['error' => 'Unauthorized'], 401); + return false; + } + return true; +} + +private function _readJsonBody(): array { + $raw = file_get_contents('php://input'); + $data = json_decode($raw, true); + return is_array($data) ? $data : []; +} + /** * @OA\Post( * path="/api/file/copyFiles.php", @@ -73,8 +227,8 @@ class FileController // Check user permissions (assuming loadUserPermissions() is available). $username = $_SESSION['username'] ?? ''; - $userPermissions = loadUserPermissions($username); - if (!empty($userPermissions['readOnly'])) { + $userPermissions = $this->loadPerms($username); + if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) { echo json_encode(["error" => "Read-only users are not allowed to copy files."]); exit; } @@ -106,6 +260,12 @@ class FileController exit; } + // Scope + ownership on source; scope on destination + $violation = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions); + if ($violation) { http_response_code(403); echo json_encode(["error"=>$violation]); return; } + $dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions); + if ($dv) { http_response_code(403); echo json_encode(["error"=>$dv]); return; } + // Delegate to the model. $result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files); echo json_encode($result); @@ -177,7 +337,7 @@ class FileController // Load user's permissions. $username = $_SESSION['username'] ?? ''; - $userPermissions = loadUserPermissions($username); + $userPermissions = $this->loadPerms($username); if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) { echo json_encode(["error" => "Read-only users are not allowed to delete files."]); exit; @@ -199,6 +359,10 @@ class FileController } $folder = trim($folder, "/\\ "); + // Scope + ownership + $violation = $this->enforceScopeAndOwnership($folder, $data['files'], $username, $userPermissions); + if ($violation) { http_response_code(403); echo json_encode(["error"=>$violation]); return; } + // Delegate to the FileModel. $result = FileModel::deleteFiles($folder, $data['files']); echo json_encode($result); @@ -271,8 +435,8 @@ class FileController // Verify that the user is not read-only. $username = $_SESSION['username'] ?? ''; - $userPermissions = loadUserPermissions($username); - if (!empty($userPermissions['readOnly'])) { + $userPermissions = $this->loadPerms($username); + if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) { echo json_encode(["error" => "Read-only users are not allowed to move files."]); exit; } @@ -303,6 +467,12 @@ class FileController exit; } + // Scope + ownership on source; scope on destination + $violation = $this->enforceScopeAndOwnership($sourceFolder, $data['files'], $username, $userPermissions); + if ($violation) { http_response_code(403); echo json_encode(["error"=>$violation]); return; } + $dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions); + if ($dv) { http_response_code(403); echo json_encode(["error"=>$dv]); return; } + // Delegate to the model. $result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['files']); echo json_encode($result); @@ -351,64 +521,63 @@ class FileController * @return void Outputs a JSON response. */ public function renameFile() - { - header('Content-Type: application/json'); - header("Cache-Control: no-cache, no-store, must-revalidate"); - header("Pragma: no-cache"); - header("Expires: 0"); +{ + $this->_jsonStart(); + try { + if (!$this->_checkCsrf()) return; + if (!$this->_requireAuth()) return; - // --- CSRF Protection --- - $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"]); - exit; - } - - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } - - // Verify user permissions. $username = $_SESSION['username'] ?? ''; - $userPermissions = loadUserPermissions($username); - if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) { - echo json_encode(["error" => "Read-only users are not allowed to rename files."]); - exit; + $userPermissions = $this->loadPerms($username); + if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) { + $this->_jsonOut(["error" => "Read-only users are not allowed to rename files."], 403); + return; } - // Get JSON input. - $data = json_decode(file_get_contents("php://input"), true); - if (!$data || !isset($data['folder']) || !isset($data['oldName']) || !isset($data['newName'])) { - http_response_code(400); - echo json_encode(["error" => "Invalid input"]); - exit; + $data = $this->_readJsonBody(); + if (!$data || !isset($data['folder'], $data['oldName'], $data['newName'])) { + $this->_jsonOut(["error" => "Invalid input"], 400); + return; } - $folder = trim($data['folder']) ?: 'root'; - // Validate folder: allow letters, numbers, underscores, dashes, spaces, and forward slashes. + $folder = trim((string)$data['folder']) ?: 'root'; + $oldName = basename(trim((string)$data['oldName'])); + $newName = basename(trim((string)$data['newName'])); + if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { - echo json_encode(["error" => "Invalid folder name"]); - exit; + $this->_jsonOut(["error" => "Invalid folder name"], 400); + return; + } + if ($oldName === '' || !preg_match(REGEX_FILE_NAME, $oldName)) { + $this->_jsonOut(["error" => "Invalid old file name."], 400); + return; + } + if ($newName === '' || !preg_match(REGEX_FILE_NAME, $newName)) { + $this->_jsonOut(["error" => "Invalid new file name."], 400); + return; } - $oldName = basename(trim($data['oldName'])); - $newName = basename(trim($data['newName'])); + // Non-admin must own the original + $violation = $this->enforceScopeAndOwnership($folder, [$oldName], $username, $userPermissions); + if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } - // Validate file names. - if (!preg_match(REGEX_FILE_NAME, $oldName) || !preg_match(REGEX_FILE_NAME, $newName)) { - echo json_encode(["error" => "Invalid file name."]); - exit; - } - - // Delegate the renaming operation to the model. $result = FileModel::renameFile($folder, $oldName, $newName); - echo json_encode($result); + if (!is_array($result)) { + throw new RuntimeException('FileModel::renameFile returned non-array'); + } + if (isset($result['error'])) { + $this->_jsonOut($result, 400); + return; + } + $this->_jsonOut($result); + + } catch (Throwable $e) { + error_log('FileController::renameFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); + $this->_jsonOut(['error' => 'Internal server error while renaming file.'], 500); + } finally { + $this->_jsonEnd(); } +} /** * @OA\Post( @@ -452,63 +621,75 @@ class FileController * @return void Outputs a JSON response. */ public function saveFile() - { - header('Content-Type: application/json'); - - // --- CSRF Protection --- - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $receivedToken = $headersArr['x-csrf-token'] ?? ''; - if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) { - http_response_code(403); - echo json_encode(["error" => "Invalid CSRF token"]); - exit; - } - - // --- Authentication Check --- - if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } +{ + $this->_jsonStart(); + try { + if (!$this->_checkCsrf()) return; + if (!$this->_requireAuth()) return; $username = $_SESSION['username'] ?? ''; - // --- Read‑only check --- - $userPermissions = loadUserPermissions($username); - if ($username && !empty($userPermissions['readOnly'])) { - echo json_encode(["error" => "Read-only users are not allowed to save files."]); - exit; + $userPermissions = $this->loadPerms($username); + if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) { + $this->_jsonOut(["error" => "Read-only users are not allowed to save files."], 403); + return; } - // --- Input parsing --- - $data = json_decode(file_get_contents("php://input"), true); + $data = $this->_readJsonBody(); if (empty($data) || !isset($data["fileName"], $data["content"])) { - http_response_code(400); - echo json_encode(["error" => "Invalid request data", "received" => $data]); - exit; + $this->_jsonOut(["error" => "Invalid request data"], 400); + return; } - $fileName = basename($data["fileName"]); - $folder = isset($data["folder"]) ? trim($data["folder"]) : "root"; + $fileName = basename(trim((string)$data["fileName"])); + $folder = isset($data["folder"]) ? trim((string)$data["folder"]) : "root"; - // --- Folder validation --- + if ($fileName === '' || !preg_match(REGEX_FILE_NAME, $fileName)) { + $this->_jsonOut(["error" => "Invalid file name."], 400); + return; + } if (strtolower($folder) !== "root" && !preg_match(REGEX_FOLDER_NAME, $folder)) { - echo json_encode(["error" => "Invalid folder name"]); - exit; + $this->_jsonOut(["error" => "Invalid folder name"], 400); + return; } - $folder = trim($folder, "/\\ "); - // --- Delegate to model, passing the uploader --- - // Make sure FileModel::saveFile signature is: - // saveFile(string $folder, string $fileName, $content, ?string $uploader = null) - $result = FileModel::saveFile( - $folder, - $fileName, - $data["content"], - $username // ← pass the real uploader here - ); + // Folder-only users may only write within their scope + $dv = $this->enforceFolderScope($folder, $username, $userPermissions); + if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } - echo json_encode($result); + // If overwriting, enforce ownership for non-admins + $baseDir = rtrim(UPLOAD_DIR, '/\\'); + $dir = (strtolower($folder) === 'root') ? $baseDir : $baseDir . DIRECTORY_SEPARATOR . $folder; + $path = $dir . DIRECTORY_SEPARATOR . $fileName; + if (is_file($path)) { + $violation = $this->enforceScopeAndOwnership($folder, [$fileName], $username, $userPermissions); + if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } + } + + // Server-side guard: block saving executable/server-side script types + $deny = ['php','phtml','phar','php3','php4','php5','php7','php8','pht','shtml','cgi','fcgi']; + $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); + if (in_array($ext, $deny, true)) { + $this->_jsonOut(['error' => 'Saving this file type is not allowed.'], 400); + return; + } + + $result = FileModel::saveFile($folder, $fileName, (string)$data["content"], $username); + if (!is_array($result)) { + throw new RuntimeException('FileModel::saveFile returned non-array'); + } + if (isset($result['error'])) { + $this->_jsonOut($result, 400); + return; + } + $this->_jsonOut($result); + + } catch (Throwable $e) { + error_log('FileController::saveFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); + $this->_jsonOut(['error' => 'Internal server error while saving file.'], 500); + } finally { + $this->_jsonEnd(); } +} /** * @OA\Get( @@ -582,6 +763,23 @@ class FileController exit; } +// Ownership enforcement (allow admin OR bypassOwnership) +$username = $_SESSION['username'] ?? ''; +$userPermissions = $this->loadPerms($username); + +$ignoreOwnership = $this->isAdmin($userPermissions) + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + +if (!$ignoreOwnership) { + $meta = $this->loadFolderMetadata($folder); + if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) { + http_response_code(403); + echo json_encode(["error" => "Forbidden: you are not the owner of this file."]); + exit; + } +} + + // Retrieve download info from the model. $downloadInfo = FileModel::getDownloadInfo($folder, $file); if (isset($downloadInfo['error'])) { @@ -676,6 +874,13 @@ class FileController exit; } + if (!$this->isAdmin($userPermissions) && array_key_exists('canZip', $userPermissions) && !$userPermissions['canZip']) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(["error" => "ZIP downloads are not allowed for your account."]); + exit; + } + // Read and decode JSON input. $data = json_decode(file_get_contents("php://input"), true); if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) { @@ -701,6 +906,22 @@ class FileController } } +// Ownership enforcement (allow admin OR bypassOwnership) +$username = $_SESSION['username'] ?? ''; +$userPermissions = $this->loadPerms($username); + +$ignoreOwnership = $this->isAdmin($userPermissions) + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + +if (!$ignoreOwnership) { + $meta = $this->loadFolderMetadata($folder); + if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) { + http_response_code(403); + echo json_encode(["error" => "Forbidden: you are not the owner of this file."]); + exit; + } +} + // Create ZIP archive using FileModel. $result = FileModel::createZipArchive($folder, $files); if (isset($result['error'])) { @@ -819,6 +1040,12 @@ class FileController } } + // Folder-only users can only extract inside their subtree + $username = $_SESSION['username'] ?? ''; + $userPermissions = $this->loadPerms($username); + $dv = $this->enforceFolderScope($folder, $username, $userPermissions); + if ($dv) { http_response_code(403); echo json_encode(["error"=>$dv]); return; } + // Delegate to the model. $result = FileModel::extractZipArchive($folder, $files); echo json_encode($result); @@ -1078,13 +1305,19 @@ class FileController // Check user permissions. $username = $_SESSION['username'] ?? ''; - $userPermissions = loadUserPermissions($username); - if ($username && !empty($userPermissions['readOnly'])) { + $userPermissions = $this->loadPerms($username); + if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) { http_response_code(403); echo json_encode(["error" => "Read-only users are not allowed to create share links."]); exit; } + if (!$this->isAdmin($userPermissions) && array_key_exists('canShare', $userPermissions) && !$userPermissions['canShare']) { + http_response_code(403); + echo json_encode(["error" => "You are not allowed to create share links."]); + exit; + } + // Parse POST JSON input. $input = json_decode(file_get_contents("php://input"), true); if (!$input) { @@ -1107,6 +1340,23 @@ class FileController exit; } + // Non-admins can only share their own files +// Ownership enforcement (allow admin OR bypassOwnership) +$username = $_SESSION['username'] ?? ''; +$userPermissions = $this->loadPerms($username); + +$ignoreOwnership = $this->isAdmin($userPermissions) + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + +if (!$ignoreOwnership) { + $meta = $this->loadFolderMetadata($folder); + if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) { + http_response_code(403); + echo json_encode(["error" => "Forbidden: you are not the owner of this file."]); + exit; + } +} + // Convert the provided value+unit into seconds switch ($unit) { case 'seconds': @@ -1349,7 +1599,7 @@ class FileController // Delegate deletion to the model. $result = FileModel::deleteTrashFiles($filesToDelete); - // Build a human‑friendly success or error message + // Build a human-friendly success or error message if (!empty($result['deleted'])) { $count = count($result['deleted']); $msg = "Trash item" . ($count === 1 ? "" : "s") . " deleted: " . implode(", ", $result['deleted']); @@ -1469,7 +1719,7 @@ class FileController // Check that the user is not read-only. $username = $_SESSION['username'] ?? ''; - $userPermissions = loadUserPermissions($username); + $userPermissions = $this->loadPerms($username); if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) { echo json_encode(["error" => "Read-only users are not allowed to file tags"]); exit; @@ -1502,6 +1752,22 @@ class FileController exit; } +// Ownership enforcement (allow admin OR bypassOwnership) +$username = $_SESSION['username'] ?? ''; +$userPermissions = $this->loadPerms($username); + +$ignoreOwnership = $this->isAdmin($userPermissions) + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + +if (!$ignoreOwnership) { + $meta = $this->loadFolderMetadata($folder); + if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) { + http_response_code(403); + echo json_encode(["error" => "Forbidden: you are not the owner of this file."]); + exit; + } +} + // Delegate to the model. $result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete); echo json_encode($result); @@ -1545,32 +1811,96 @@ class FileController * @return void Outputs JSON response. */ public function getFileList(): void - { - header('Content-Type: application/json'); +{ + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + header('Content-Type: application/json; charset=utf-8'); + + set_error_handler(function ($severity, $message, $file, $line) { + if (!(error_reporting() & $severity)) return; + throw new ErrorException($message, 0, $severity, $file, $line); + }); + + try { + if (empty($_SESSION['username'])) { http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; + echo json_encode(['error' => 'Unauthorized']); + return; } - // Retrieve the folder from GET; default to "root". - $folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; + if (!is_dir(META_DIR)) { + @mkdir(META_DIR, 0775, true); + } + + $folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root'; + if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); - echo json_encode(["error" => "Invalid folder name."]); - exit; + echo json_encode(['error' => 'Invalid folder name.']); + return; + } + + if (!is_dir(UPLOAD_DIR)) { + http_response_code(500); + echo json_encode(['error' => 'Uploads directory not found.']); + return; } - // Delegate to the model. $result = FileModel::getFileList($folder); + + if ($result === false || $result === null) { + http_response_code(500); + echo json_encode(['error' => 'File model failed.']); + return; + } + if (!is_array($result)) { + throw new RuntimeException('FileModel::getFileList returned a non-array.'); + } if (isset($result['error'])) { http_response_code(400); + echo json_encode($result); + return; } - echo json_encode($result); - exit; + + // --- viewOwnOnly (for non-admins) --- + $username = $_SESSION['username'] ?? ''; + $perms = $this->loadPerms($username); + $admin = $this->isAdmin($perms); + $ownOnly = !$admin && !empty($perms['viewOwnOnly']); + + if ($ownOnly && isset($result['files'])) { + $files = $result['files']; + if (is_array($files) && array_keys($files) !== range(0, count($files) - 1)) { + // associative: name => meta + $filtered = []; + foreach ($files as $name => $meta) { + if (!isset($meta['uploader']) || strcasecmp((string)$meta['uploader'], $username) === 0) { + $filtered[$name] = $meta; + } + } + $result['files'] = $filtered; + } elseif (is_array($files)) { + // list of objects + $result['files'] = array_values(array_filter($files, function ($f) use ($username) { + return !isset($f['uploader']) || strcasecmp((string)$f['uploader'], $username) === 0; + })); + } + } + + echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return; + + } catch (Throwable $e) { + error_log('FileController::getFileList error: ' . $e->getMessage() . + ' in ' . $e->getFile() . ':' . $e->getLine()); + http_response_code(500); + echo json_encode(['error' => 'Internal server error while listing files.']); + } finally { + restore_error_handler(); } +} /** * GET /api/file/getShareLinks.php @@ -1631,26 +1961,44 @@ class FileController * POST /api/file/createFile.php */ public function createFile(): void - { +{ + $this->_jsonStart(); + try { + if (!$this->_requireAuth()) return; - // Check user permissions (assuming loadUserPermissions() is available). $username = $_SESSION['username'] ?? ''; - $userPermissions = loadUserPermissions($username); - if (!empty($userPermissions['readOnly'])) { - echo json_encode(["error" => "Read-only users are not allowed to create files."]); - exit; + $userPermissions = $this->loadPerms($username); + if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) { + $this->_jsonOut(["error" => "Read-only users are not allowed to create files."], 403); + return; } - $body = json_decode(file_get_contents('php://input'), true); - $folder = $body['folder'] ?? 'root'; - $filename = $body['name'] ?? ''; - $result = FileModel::createFile($folder, $filename, $_SESSION['username'] ?? 'Unknown'); + $body = $this->_readJsonBody(); + $folder = isset($body['folder']) ? trim((string)$body['folder']) : 'root'; + $filename = isset($body['name']) ? basename(trim((string)$body['name'])) : ''; - if (!$result['success']) { - http_response_code($result['code'] ?? 400); - echo json_encode(['success'=>false,'error'=>$result['error']]); - } else { - echo json_encode(['success'=>true]); + if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + $this->_jsonOut(["error" => "Invalid folder name."], 400); return; } + if ($filename === '' || !preg_match(REGEX_FILE_NAME, $filename)) { + $this->_jsonOut(["error" => "Invalid file name."], 400); return; + } + + $dv = $this->enforceFolderScope($folder, $username, $userPermissions); + if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } + + $result = FileModel::createFile($folder, $filename, $username); + if (empty($result['success'])) { + $this->_jsonOut(['success'=>false,'error'=>$result['error'] ?? 'Failed to create file'], $result['code'] ?? 400); + return; + } + $this->_jsonOut(['success'=>true]); + + } catch (Throwable $e) { + error_log('FileController::createFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); + $this->_jsonOut(['error' => 'Internal server error while creating file.'], 500); + } finally { + $this->_jsonEnd(); } } +} \ No newline at end of file diff --git a/src/controllers/FolderController.php b/src/controllers/FolderController.php index 9d3f823..65495a6 100644 --- a/src/controllers/FolderController.php +++ b/src/controllers/FolderController.php @@ -6,6 +6,94 @@ require_once PROJECT_ROOT . '/src/models/FolderModel.php'; class FolderController { + // ---- Helpers ----------------------------------------------------------- + private static function getHeadersLower(): array + { + // getallheaders() may not exist on some SAPIs + if (function_exists('getallheaders')) { + $h = getallheaders(); + if (is_array($h)) return array_change_key_case($h, CASE_LOWER); + } + $headers = []; + foreach ($_SERVER as $k => $v) { + if (strpos($k, 'HTTP_') === 0) { + $name = strtolower(str_replace('_', '-', substr($k, 5))); + $headers[$name] = $v; + } + } + return $headers; + } + + private static function requireCsrf(): void + { + $headers = self::getHeadersLower(); + $received = trim($headers['x-csrf-token'] ?? ($_POST['csrfToken'] ?? '')); + if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Invalid CSRF token']); + exit; + } + } + + private static function requireAuth(): void + { + if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Unauthorized']); + exit; + } + } + + private static function requireNotReadOnly(): void + { + $username = $_SESSION['username'] ?? ''; + $perms = loadUserPermissions($username); + if ($username && !empty($perms['readOnly'])) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Read-only users are not allowed to perform this action.']); + exit; + } + } + + private static function requireAdmin(): void + { + if (empty($_SESSION['isAdmin'])) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Admin privileges required.']); + exit; + } + } + + private static function formatBytes(int $bytes): string + { + if ($bytes < 1024) { + return $bytes . " B"; + } elseif ($bytes < 1048576) { + return round($bytes / 1024, 2) . " KB"; + } elseif ($bytes < 1073741824) { + return round($bytes / 1048576, 2) . " MB"; + } else { + return round($bytes / 1073741824, 2) . " GB"; + } + } + + private function enforceFolderScope(string $folder, string $username, array $userPermissions): ?string { + if ($this->isAdmin($userPermissions) || !empty($userPermissions['bypassFolderScope'])) return null; + if (!$this->isFolderOnly($userPermissions)) return null; + + $folder = trim($folder); + if ($folder !== '' && strtolower($folder) !== 'root') { + if ($folder !== $username && strpos($folder, $username . '/') !== 0) { + return "Forbidden: folder scope violation."; + } + } + return null; + } + /** * @OA\Post( * path="/api/folder/createFolder.php", @@ -41,74 +129,41 @@ class FolderController * description="Invalid CSRF token or permission denied" * ) * ) - * - * Creates a new folder in the upload directory, optionally under a parent folder. - * - * @return void Outputs a JSON response. */ public function createFolder(): void { header('Content-Type: application/json'); - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } - - // Ensure the request method is POST. + self::requireAuth(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - echo json_encode(['error' => 'Invalid request method.']); + http_response_code(405); + echo json_encode(['error' => 'Method not allowed.']); exit; } + self::requireCsrf(); + self::requireNotReadOnly(); - // CSRF check. - $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.']); - exit; - } - - // Check permissions. - $username = $_SESSION['username'] ?? ''; - $userPermissions = loadUserPermissions($username); - if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) { - http_response_code(403); - echo json_encode([ - "success" => false, - "error" => "Read-only users are not allowed to create folders." - ]); - exit; - } - - // Get and decode JSON input. $input = json_decode(file_get_contents('php://input'), true); if (!isset($input['folderName'])) { + http_response_code(400); echo json_encode(['error' => 'Folder name not provided.']); exit; } $folderName = trim($input['folderName']); - $parent = isset($input['parent']) ? trim($input['parent']) : ""; + $parent = isset($input['parent']) ? trim($input['parent']) : ""; - // Basic sanitation for folderName. if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { http_response_code(400); echo json_encode(['error' => 'Invalid folder name.']); exit; } - - // Optionally sanitize the parent. if ($parent && !preg_match(REGEX_FOLDER_NAME, $parent)) { http_response_code(400); echo json_encode(['error' => 'Invalid parent folder name.']); exit; } - // Delegate to FolderModel. $result = FolderModel::createFolder($folderName, $parent); echo json_encode($result); exit; @@ -148,60 +203,39 @@ class FolderController * description="Invalid CSRF token or permission denied" * ) * ) - * - * Deletes a folder if it is empty and not the root folder. - * - * @return void Outputs a JSON response. */ public function deleteFolder(): void { header('Content-Type: application/json'); - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } - - // Ensure the request is a POST. + self::requireAuth(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - echo json_encode(["error" => "Invalid request method."]); + http_response_code(405); + echo json_encode(["error" => "Method not allowed."]); exit; } + self::requireCsrf(); + self::requireNotReadOnly(); - // CSRF Protection. - $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."]); - exit; - } - - // Check user permissions. - $username = $_SESSION['username'] ?? ''; - $userPermissions = loadUserPermissions($username); - if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) { - echo json_encode(["error" => "Read-only users are not allowed to delete folders."]); - exit; - } - - // Get and decode JSON input. $input = json_decode(file_get_contents('php://input'), true); if (!isset($input['folder'])) { + http_response_code(400); echo json_encode(["error" => "Folder name not provided."]); exit; } $folder = trim($input['folder']); - // Prevent deletion of the root folder. if (strtolower($folder) === 'root') { + http_response_code(400); echo json_encode(["error" => "Cannot delete root folder."]); exit; } + if (!preg_match(REGEX_FOLDER_NAME, $folder)) { + http_response_code(400); + echo json_encode(["error" => "Invalid folder name."]); + exit; + } - // Delegate to the model. $result = FolderModel::deleteFolder($folder); echo json_encode($result); exit; @@ -242,48 +276,23 @@ class FolderController * description="Invalid CSRF token or permission denied" * ) * ) - * - * Renames a folder by validating inputs and delegating to the model. - * - * @return void Outputs a JSON response. */ public function renameFolder(): void { header('Content-Type: application/json'); - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } - - // Ensure the request method is POST. + self::requireAuth(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - echo json_encode(['error' => 'Invalid request method.']); + http_response_code(405); + echo json_encode(['error' => 'Method not allowed.']); exit; } + self::requireCsrf(); + self::requireNotReadOnly(); - // CSRF Protection. - $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."]); - exit; - } - - // Check that the user is not read-only. - $username = $_SESSION['username'] ?? ''; - $userPermissions = loadUserPermissions($username); - if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) { - echo json_encode(["error" => "Read-only users are not allowed to rename folders."]); - exit; - } - - // Get JSON input. $input = json_decode(file_get_contents('php://input'), true); if (!isset($input['oldFolder']) || !isset($input['newFolder'])) { + http_response_code(400); echo json_encode(['error' => 'Required folder names not provided.']); exit; } @@ -291,13 +300,12 @@ class FolderController $oldFolder = trim($input['oldFolder']); $newFolder = trim($input['newFolder']); - // Validate folder names. if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) { + http_response_code(400); echo json_encode(['error' => 'Invalid folder name(s).']); exit; } - // Delegate to the model. $result = FolderModel::renameFolder($oldFolder, $newFolder); echo json_encode($result); exit; @@ -334,21 +342,19 @@ class FolderController * description="Bad request" * ) * ) - * - * Retrieves the folder list and associated metadata. - * - * @return void Outputs JSON response. */ public function getFolderList(): void { header('Content-Type: application/json'); - if (empty($_SESSION['authenticated'])) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); + self::requireAuth(); + + $parent = $_GET['folder'] ?? null; + if ($parent !== null && $parent !== '' && $parent !== 'root' && !preg_match(REGEX_FOLDER_NAME, $parent)) { + http_response_code(400); + echo json_encode(["error" => "Invalid folder name."]); exit; } - $parent = $_GET['folder'] ?? null; $folderList = FolderModel::getFolderList($parent); echo json_encode($folderList); exit; @@ -400,34 +406,13 @@ class FolderController * description="Share folder not found" * ) * ) - * - * Displays a shared folder with file listings, pagination, and an upload container if allowed. - * - * @return void Outputs HTML content. */ - - function formatBytes($bytes) - { - if ($bytes < 1024) { - return $bytes . " B"; - } elseif ($bytes < 1024 * 1024) { - return round($bytes / 1024, 2) . " KB"; - } elseif ($bytes < 1024 * 1024 * 1024) { - return round($bytes / (1024 * 1024), 2) . " MB"; - } else { - return round($bytes / (1024 * 1024 * 1024), 2) . " GB"; - } - } - public function shareFolder(): void { - // Retrieve GET parameters. - $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); + $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); $providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING); - $page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT); - if ($page === false || $page < 1) { - $page = 1; - } + $page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT); + if ($page === false || $page < 1) $page = 1; if (empty($token)) { http_response_code(400); @@ -436,57 +421,24 @@ class FolderController exit; } - // Delegate to the model. $data = FolderModel::getSharedFolderData($token, $providedPass, $page); - // If a password is needed, output an HTML form. if (isset($data['needs_password']) && $data['needs_password'] === true) { - header("Content-Type: text/html; charset=utf-8"); -?> + header("Content-Type: text/html; charset=utf-8"); ?> - Enter Password -

Folder Protected

@@ -499,13 +451,10 @@ class FolderController
- - + header("Content-Type: text/html; charset=utf-8"); ?> - Shared Folder: <?php echo htmlspecialchars($folderName, ENT_QUOTES, 'UTF-8'); ?> -

Shared Folder:

- -

This folder is empty.

- - - - + - - - - - - + + + + + +
FilenameSize
FilenameSize
- - - - -
+ + + +
- - - - +

Upload File - ( max size) + ( max size)

-

@@ -785,10 +579,8 @@ class FolderController
- - + + - - "Unauthorized"]); - exit; - } + self::requireAuth(); + self::requireCsrf(); + self::requireNotReadOnly(); - // Read-only check - $username = $_SESSION['username'] ?? ''; - $perms = loadUserPermissions($username); - if ($username && !empty($perms['readOnly'])) { - http_response_code(403); - echo json_encode(["error" => "Read-only users are not allowed to create share folders."]); - exit; - } - - // Input - $in = json_decode(file_get_contents("php://input"), true); - if (!$in || !isset($in['folder'])) { - http_response_code(400); - echo json_encode(["error" => "Invalid input."]); - exit; - } - - $folder = trim($in['folder']); - $value = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60; - $unit = $in['expirationUnit'] ?? 'minutes'; - $password = $in['password'] ?? ''; - $allowUpload = intval($in['allowUpload'] ?? 0); - - // Folder name validation - if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { - http_response_code(400); - echo json_encode(["error" => "Invalid folder name."]); - exit; - } - - // Convert to seconds - switch ($unit) { - case 'seconds': - $seconds = $value; - break; - case 'hours': - $seconds = $value * 3600; - break; - case 'days': - $seconds = $value * 86400; - break; - case 'minutes': - default: - $seconds = $value * 60; - break; - } - - // Delegate - $res = FolderModel::createShareFolderLink($folder, $seconds, $password, $allowUpload); - echo json_encode($res); + $in = json_decode(file_get_contents("php://input"), true); + if (!$in || !isset($in['folder'])) { + http_response_code(400); + echo json_encode(["error" => "Invalid input."]); exit; } + $folder = trim($in['folder']); + $value = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60; + $unit = $in['expirationUnit'] ?? 'minutes'; + $password = $in['password'] ?? ''; + $allowUpload = intval($in['allowUpload'] ?? 0); + + // Basic folder name guard + if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + http_response_code(400); + echo json_encode(["error" => "Invalid folder name."]); + exit; + } + + // ---- Permissions ---- + $username = $_SESSION['username'] ?? ''; + $perms = loadUserPermissions($username) ?: []; + + $isAdmin = !empty($perms['admin']) || !empty($perms['isAdmin']); + $canShare = $isAdmin || ($perms['canShare'] ?? (defined('DEFAULT_CAN_SHARE') ? DEFAULT_CAN_SHARE : true)); + if (!$canShare) { + http_response_code(403); + echo json_encode(["error" => "Sharing is not permitted for your account."]); + exit; + } + + // Folder-only scope: non-admins must stay inside their subtree and cannot share root + $folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']); + if (!$isAdmin && strcasecmp($folder, 'root') === 0) { + http_response_code(403); + echo json_encode(["error" => "Only admins may share the root folder."]); + exit; + } + if (!$isAdmin && $folderOnly && $folder !== 'root') { + if ($folder !== $username && strpos($folder, $username . '/') !== 0) { + http_response_code(403); + echo json_encode(["error" => "Forbidden: folder scope violation."]); + exit; + } + } + + // Ownership check unless bypassOwnership + $ignoreOwnership = $isAdmin || ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + if (!$ignoreOwnership) { + // Only checks top-level files (sharing UI lists top-level files only) + $metaFile = (strcasecmp($folder, 'root') === 0) + ? META_DIR . 'root_metadata.json' + : META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json'; + + $meta = (is_file($metaFile) ? json_decode(@file_get_contents($metaFile), true) : []) ?: []; + foreach ($meta as $fname => $m) { + if (($m['uploader'] ?? null) !== $username) { + http_response_code(403); + echo json_encode(["error" => "Forbidden: you don't own all files in this folder."]); + exit; + } + } + } + + // If user is not allowed to upload generally, block share-with-upload + if ($allowUpload === 1 && !empty($perms['disableUpload']) && !$isAdmin) { + http_response_code(403); + echo json_encode(["error" => "You cannot enable uploads on shared folders."]); + exit; + } + + // Expiration seconds (cap at 1 year) + if ($value < 1) $value = 1; + switch ($unit) { + case 'seconds': $seconds = $value; break; + case 'hours': $seconds = $value * 3600; break; + case 'days': $seconds = $value * 86400; break; + case 'minutes': + default: $seconds = $value * 60; break; + } + $seconds = min($seconds, 31536000); + + // Create share link + $res = FolderModel::createShareFolderLink($folder, $seconds, $password, $allowUpload); + echo json_encode($res); + exit; +} + /** * @OA\Get( * path="/api/folder/downloadSharedFile.php", @@ -950,16 +770,11 @@ class FolderController * description="File not found" * ) * ) - * - * Downloads a file from a shared folder based on a token. - * - * @return void Outputs the file with proper headers. */ public function downloadSharedFile(): void { - // Retrieve and sanitize GET parameters. $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); - $file = filter_input(INPUT_GET, 'file', FILTER_SANITIZE_STRING); + $file = filter_input(INPUT_GET, 'file', FILTER_SANITIZE_STRING); if (empty($token) || empty($file)) { http_response_code(400); @@ -968,8 +783,16 @@ class FolderController exit; } - // Delegate to the model. - $result = FolderModel::getSharedFileInfo($token, $file); + // Extra safety: enforce filename policy before delegating + $basename = basename($file); + if (!preg_match(REGEX_FILE_NAME, $basename)) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "Invalid file name."]); + exit; + } + + $result = FolderModel::getSharedFileInfo($token, $basename); if (isset($result['error'])) { http_response_code(404); header('Content-Type: application/json'); @@ -978,17 +801,18 @@ class FolderController } $realFilePath = $result['realFilePath']; - $mimeType = $result['mimeType']; + $mimeType = $result['mimeType']; - // Serve the file. + header('X-Content-Type-Options: nosniff'); header("Content-Type: " . $mimeType); $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); - if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'])) { + if (in_array($ext, ['jpg','jpeg','png','gif','bmp','webp','svg','ico'])) { header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"'); } else { header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"'); } - header('Content-Length: ' . filesize($realFilePath)); + $size = @filesize($realFilePath); + if (is_int($size)) header('Content-Length: ' . $size); readfile($realFilePath); exit; } @@ -1029,14 +853,9 @@ class FolderController * description="Server error during file move" * ) * ) - * - * Handles uploading a file to a shared folder. - * - * @return void Redirects upon successful upload or outputs JSON errors. */ public function uploadToSharedFolder(): void { - // Ensure request is POST. if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); header('Content-Type: application/json'); @@ -1044,7 +863,6 @@ class FolderController exit; } - // Ensure the share token is provided. if (empty($_POST['token'])) { http_response_code(400); header('Content-Type: application/json'); @@ -1053,7 +871,6 @@ class FolderController } $token = trim($_POST['token']); - // Delegate the upload to the model. if (!isset($_FILES['fileToUpload'])) { http_response_code(400); header('Content-Type: application/json'); @@ -1062,6 +879,24 @@ class FolderController } $fileUpload = $_FILES['fileToUpload']; + // Quick surface error mapping + if (!empty($fileUpload['error']) && $fileUpload['error'] !== UPLOAD_ERR_OK) { + $map = [ + UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive.', + UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.', + UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded.', + UPLOAD_ERR_NO_FILE => 'No file was uploaded.', + UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder.', + UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.', + UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload.' + ]; + $msg = $map[$fileUpload['error']] ?? 'Upload error.'; + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(['error' => $msg]); + exit; + } + $result = FolderModel::uploadToSharedFolder($token, $fileUpload); if (isset($result['error'])) { http_response_code(400); @@ -1070,10 +905,7 @@ class FolderController exit; } - // Optionally, set a flash message in session. $_SESSION['upload_message'] = "File uploaded successfully."; - - // Redirect back to the shared folder view. $redirectUrl = "/api/folder/shareFolder.php?token=" . urlencode($token); header("Location: " . $redirectUrl); exit; @@ -1085,6 +917,9 @@ class FolderController public function getAllShareFolderLinks(): void { header('Content-Type: application/json'); + self::requireAuth(); + self::requireAdmin(); // exposing all share folder links is an admin operation + $shareFile = META_DIR . 'share_folder_links.json'; $links = file_exists($shareFile) ? json_decode(file_get_contents($shareFile), true) ?? [] @@ -1092,7 +927,6 @@ class FolderController $now = time(); $cleaned = []; - // 1) Remove expired foreach ($links as $token => $record) { if (!empty($record['expires']) && $record['expires'] < $now) { continue; @@ -1100,7 +934,6 @@ class FolderController $cleaned[$token] = $record; } - // 2) Persist back if anything was pruned if (count($cleaned) !== count($links)) { file_put_contents($shareFile, json_encode($cleaned, JSON_PRETTY_PRINT)); } @@ -1114,8 +947,13 @@ class FolderController public function deleteShareFolderLink() { header('Content-Type: application/json'); + self::requireAuth(); + self::requireAdmin(); + self::requireCsrf(); + $token = $_POST['token'] ?? ''; if (!$token) { + http_response_code(400); echo json_encode(['success' => false, 'error' => 'No token provided']); return; } @@ -1124,6 +962,7 @@ class FolderController if ($deleted) { echo json_encode(['success' => true]); } else { + http_response_code(404); echo json_encode(['success' => false, 'error' => 'Not found']); } } diff --git a/src/controllers/UserController.php b/src/controllers/UserController.php index 6113086..4d79239 100644 --- a/src/controllers/UserController.php +++ b/src/controllers/UserController.php @@ -1,11 +1,106 @@ $v) { + $out[strtolower($k)] = $v; + } + // Fallbacks from $_SERVER if needed + foreach ($_SERVER as $k => $v) { + if (strpos($k, 'HTTP_') === 0) { + $h = strtolower(str_replace('_', '-', substr($k, 5))); + if (!isset($out[$h])) $out[$h] = $v; + } + } + return $out; + } + + /** Enforce allowed HTTP method(s); default to 405 if not allowed. */ + private static function requireMethod(array $allowed): void + { + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + if (!in_array($method, $allowed, true)) { + http_response_code(405); + header('Allow: ' . implode(', ', $allowed)); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Method not allowed']); + exit; + } + } + + /** Enforce authentication (401). */ + private static function requireAuth(): void + { + if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Unauthorized']); + exit; + } + } + + /** Enforce admin (401). */ + private static function requireAdmin(): void + { + self::requireAuth(); + if (empty($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Unauthorized']); + exit; + } + } + + /** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */ + private static function requireCsrf(): void + { + $h = self::headersLower(); + $token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? '')); + if (empty($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Invalid CSRF token']); + exit; + } + } + + /** Read JSON body (empty array if not valid). */ + private static function readJson(): array + { + $raw = file_get_contents('php://input'); + $data = json_decode($raw, true); + return is_array($data) ? $data : []; + } + + /** Convenience: set JSON content type + no-store. */ + private static function jsonHeaders(): void + { + header('Content-Type: application/json'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + } + + /* ------------------------- End helpers -------------------------- */ + /** * @OA\Get( * path="/api/getUsers.php", @@ -31,24 +126,15 @@ class UserController * ) * ) */ - public function getUsers() { - header('Content-Type: application/json'); - - // Check authentication and admin privileges. - if ( - !isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true || - !isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true - ) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } + self::jsonHeaders(); + self::requireAdmin(); // Retrieve users using the model - $users = userModel::getAllUsers(); + $users = UserModel::getAllUsers(); echo json_encode($users); + exit; } /** @@ -84,34 +170,33 @@ class UserController * ) * ) */ - public function addUser() { - // 1) Ensure JSON output and session - header('Content-Type: application/json'); + self::jsonHeaders(); + self::requireMethod(['POST']); - // 1a) Initialize CSRF token if missing + // Initialize CSRF token if missing (useful for initial page load) if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } - // 2) Determine setup mode (first-ever admin creation) + // Setup mode detection (first-run bootstrap) $usersFile = USERS_DIR . USERS_FILE; $isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1'); $setupMode = false; if ( - $isSetup && (! file_exists($usersFile) + $isSetup && (!file_exists($usersFile) || filesize($usersFile) === 0 - || trim(file_get_contents($usersFile)) === '' + || trim(@file_get_contents($usersFile)) === '' ) ) { $setupMode = true; } else { - // 3) In non-setup, enforce CSRF + auth checks - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $receivedToken = trim($headersArr['x-csrf-token'] ?? ''); + // Not setup: enforce CSRF + admin auth + $h = self::headersLower(); + $receivedToken = trim($h['x-csrf-token'] ?? ''); - // 3a) Soft-fail CSRF: on mismatch, regenerate and return new token + // Soft-fail CSRF: on mismatch, regenerate and return new token (preserve your current UX) if ($receivedToken !== $_SESSION['csrf_token']) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); header('X-CSRF-Token: ' . $_SESSION['csrf_token']); @@ -122,31 +207,15 @@ class UserController exit; } - // 3b) Must be logged in as admin - if ( - empty($_SESSION['authenticated']) - || $_SESSION['authenticated'] !== true - || empty($_SESSION['isAdmin']) - || $_SESSION['isAdmin'] !== true - ) { - echo json_encode(["error" => "Unauthorized"]); - exit; - } + self::requireAdmin(); } - // 4) Parse input - $data = json_decode(file_get_contents('php://input'), true) ?: []; + $data = self::readJson(); $newUsername = trim($data['username'] ?? ''); $newPassword = trim($data['password'] ?? ''); - // 5) Determine admin flag - if ($setupMode) { - $isAdmin = '1'; - } else { - $isAdmin = !empty($data['isAdmin']) ? '1' : '0'; - } + $isAdmin = $setupMode ? '1' : (!empty($data['isAdmin']) ? '1' : '0'); - // 6) Validate fields if ($newUsername === '' || $newPassword === '') { echo json_encode(["error" => "Username and password required"]); exit; @@ -157,11 +226,13 @@ class UserController ]); exit; } + // Keep password rules lenient to avoid breaking existing flows; enforce at least 6 chars + if (strlen($newPassword) < 6) { + echo json_encode(["error" => "Password must be at least 6 characters."]); + exit; + } - // 7) Delegate to model - $result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode); - - // 8) Return model result + $result = UserModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode); echo json_encode($result); exit; } @@ -201,54 +272,33 @@ class UserController * ) * ) */ - public function removeUser() { - header('Content-Type: application/json'); + self::jsonHeaders(); + // Accept DELETE or POST for broader compatibility + self::requireMethod(['DELETE', 'POST']); + self::requireAdmin(); + self::requireCsrf(); - // CSRF token check. - $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"]); - exit; - } + $data = self::readJson(); + $usernameToRemove = trim($data['username'] ?? ''); - // Authentication and admin check. - if ( - !isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true || - !isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true - ) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } - - // Retrieve JSON data. - $data = json_decode(file_get_contents("php://input"), true); - $usernameToRemove = trim($data["username"] ?? ""); - - if (!$usernameToRemove) { + if ($usernameToRemove === '') { echo json_encode(["error" => "Username is required"]); exit; } - - // Validate the username format. if (!preg_match(REGEX_USER, $usernameToRemove)) { echo json_encode(["error" => "Invalid username format"]); exit; } - - // Prevent removal of the currently logged-in user. - if (isset($_SESSION['username']) && $_SESSION['username'] === $usernameToRemove) { + if (!empty($_SESSION['username']) && $_SESSION['username'] === $usernameToRemove) { echo json_encode(["error" => "Cannot remove yourself"]); exit; } - // Delegate the removal logic to the model. - $result = userModel::removeUser($usernameToRemove); + $result = UserModel::removeUser($usernameToRemove); echo json_encode($result); + exit; } /** @@ -269,21 +319,14 @@ class UserController * ) * ) */ - public function getUserPermissions() { - header('Content-Type: application/json'); + self::jsonHeaders(); + self::requireAuth(); - // Check if the user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } - - // Delegate to the model. - $permissions = userModel::getUserPermissions(); + $permissions = UserModel::getUserPermissions(); echo json_encode($permissions); + exit; } /** @@ -331,42 +374,24 @@ class UserController * ) * ) */ - public function updateUserPermissions() { - header('Content-Type: application/json'); + self::jsonHeaders(); + // Accept PUT or POST for compatibility with clients that can't send PUT + self::requireMethod(['PUT', 'POST']); + self::requireAdmin(); + self::requireCsrf(); - // Only admins can update permissions. - if ( - !isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true || - !isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true - ) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } - - // Verify CSRF token from headers. - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $csrfToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : ''; - if (!isset($_SESSION['csrf_token']) || $csrfToken !== $_SESSION['csrf_token']) { - http_response_code(403); - echo json_encode(["error" => "Invalid CSRF token"]); - exit; - } - - // Get POST input. - $input = json_decode(file_get_contents("php://input"), true); + $input = self::readJson(); if (!isset($input['permissions']) || !is_array($input['permissions'])) { echo json_encode(["error" => "Invalid input"]); exit; } - $permissions = $input['permissions']; - // Delegate to the model. - $result = userModel::updateUserPermissions($permissions); + $result = UserModel::updateUserPermissions($permissions); echo json_encode($result); + exit; } /** @@ -406,41 +431,25 @@ class UserController * ) * ) */ - public function changePassword() { - header('Content-Type: application/json'); - - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } + self::jsonHeaders(); + self::requireMethod(['POST']); + self::requireAuth(); + self::requireCsrf(); $username = $_SESSION['username'] ?? ''; - if (!$username) { + if ($username === '') { echo json_encode(["error" => "No username in session"]); exit; } - // CSRF token check. - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : ''; - if ($receivedToken !== $_SESSION['csrf_token']) { - http_response_code(403); - echo json_encode(["error" => "Invalid CSRF token"]); - exit; - } - - // Get POST data. - $data = json_decode(file_get_contents("php://input"), true); - $oldPassword = trim($data["oldPassword"] ?? ""); - $newPassword = trim($data["newPassword"] ?? ""); + $data = self::readJson(); + $oldPassword = trim($data["oldPassword"] ?? ""); + $newPassword = trim($data["newPassword"] ?? ""); $confirmPassword = trim($data["confirmPassword"] ?? ""); - // Validate input. - if (!$oldPassword || !$newPassword || !$confirmPassword) { + if ($oldPassword === '' || $newPassword === '' || $confirmPassword === '') { echo json_encode(["error" => "All fields are required."]); exit; } @@ -448,10 +457,14 @@ class UserController echo json_encode(["error" => "New passwords do not match."]); exit; } + if (strlen($newPassword) < 6) { + echo json_encode(["error" => "Password must be at least 6 characters."]); + exit; + } - // Delegate password change logic to the model. - $result = userModel::changePassword($username, $oldPassword, $newPassword); + $result = UserModel::changePassword($username, $oldPassword, $newPassword); echo json_encode($result); + exit; } /** @@ -489,29 +502,15 @@ class UserController * ) * ) */ - public function updateUserPanel() { - header('Content-Type: application/json'); + self::jsonHeaders(); + // Accept PUT or POST for compatibility + self::requireMethod(['PUT', 'POST']); + self::requireAuth(); + self::requireCsrf(); - // Check if the user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(403); - echo json_encode(["error" => "Unauthorized"]); - exit; - } - - // Verify the CSRF token. - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $csrfToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : ''; - if (!isset($_SESSION['csrf_token']) || $csrfToken !== $_SESSION['csrf_token']) { - http_response_code(403); - echo json_encode(["error" => "Invalid CSRF token"]); - exit; - } - - // Get the POST input. - $data = json_decode(file_get_contents("php://input"), true); + $data = self::readJson(); if (!is_array($data)) { http_response_code(400); echo json_encode(["error" => "Invalid input"]); @@ -519,18 +518,16 @@ class UserController } $username = $_SESSION['username'] ?? ''; - if (!$username) { + if ($username === '') { http_response_code(400); echo json_encode(["error" => "No username in session"]); exit; } - // Extract totp_enabled, converting it to boolean. $totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false; - - // Delegate to the model. - $result = userModel::updateUserPanel($username, $totp_enabled); + $result = UserModel::updateUserPanel($username, $totp_enabled); echo json_encode($result); + exit; } /** @@ -558,43 +555,29 @@ class UserController * ) * ) */ - public function disableTOTP() { - header('Content-Type: application/json'); - - // Authentication check. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(403); - echo json_encode(["error" => "Not authenticated"]); - exit; - } + self::jsonHeaders(); + // Accept PUT or POST + self::requireMethod(['PUT', 'POST']); + self::requireAuth(); + self::requireCsrf(); $username = $_SESSION['username'] ?? ''; - if (empty($username)) { + if ($username === '') { http_response_code(400); echo json_encode(["error" => "Username not found in session"]); exit; } - // CSRF token check. - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : ''; - if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) { - http_response_code(403); - echo json_encode(["error" => "Invalid CSRF token"]); - exit; - } - - // Delegate the TOTP disabling logic to the model. - $result = userModel::disableTOTPSecret($username); - + $result = UserModel::disableTOTPSecret($username); if ($result) { echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]); } else { http_response_code(500); echo json_encode(["error" => "Failed to disable TOTP."]); } + exit; } /** @@ -636,61 +619,45 @@ class UserController * ) * ) */ - public function recoverTOTP() { - header('Content-Type: application/json'); + self::jsonHeaders(); + self::requireMethod(['POST']); + self::requireCsrf(); - // 1) Only allow POST. - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - http_response_code(405); - exit(json_encode(['status' => 'error', 'message' => 'Method not allowed'])); - } - - // 2) CSRF check. - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : ''; - if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) { - http_response_code(403); - exit(json_encode(['status' => 'error', 'message' => 'Invalid CSRF token'])); - } - - // 3) Identify the user. - $userId = $_SESSION['username'] ?? $_SESSION['pending_login_user'] ?? null; + $userId = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? null); if (!$userId) { http_response_code(401); - exit(json_encode(['status' => 'error', 'message' => 'Unauthorized'])); + echo json_encode(['status' => 'error', 'message' => 'Unauthorized']); + exit; } - - // 4) Validate userId format. if (!preg_match(REGEX_USER, $userId)) { http_response_code(400); - exit(json_encode(['status' => 'error', 'message' => 'Invalid user identifier'])); + echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']); + exit; } - // 5) Get the recovery code from input. - $inputData = json_decode(file_get_contents("php://input"), true); + $inputData = self::readJson(); $recoveryCode = $inputData['recovery_code'] ?? ''; - // 6) Delegate to the model. - $result = userModel::recoverTOTP($userId, $recoveryCode); + $result = UserModel::recoverTOTP($userId, $recoveryCode); - if ($result['status'] === 'ok') { - // 7) Finalize login. + if (($result['status'] ?? '') === 'ok') { + // Finalize login session_regenerate_id(true); $_SESSION['authenticated'] = true; $_SESSION['username'] = $userId; unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']); echo json_encode(['status' => 'ok']); } else { - // Set appropriate HTTP code for errors. - if ($result['message'] === 'Too many attempts. Try again later.') { + if (($result['message'] ?? '') === 'Too many attempts. Try again later.') { http_response_code(429); } else { http_response_code(400); } echo json_encode($result); } + exit; } /** @@ -722,49 +689,33 @@ class UserController * ) * ) */ - public function saveTOTPRecoveryCode() { - header('Content-Type: application/json'); + self::jsonHeaders(); + self::requireMethod(['POST']); + self::requireCsrf(); - // 1) Only allow POST requests. - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - http_response_code(405); - error_log("totp_saveCode: invalid method {$_SERVER['REQUEST_METHOD']}"); - exit(json_encode(['status' => 'error', 'message' => 'Method not allowed'])); - } - - // 2) CSRF token check. - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : ''; - if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) { - http_response_code(403); - exit(json_encode(['status' => 'error', 'message' => 'Invalid CSRF token'])); - } - - // 3) Ensure the user is authenticated. if (empty($_SESSION['username'])) { http_response_code(401); - error_log("totp_saveCode: unauthorized attempt from IP {$_SERVER['REMOTE_ADDR']}"); - exit(json_encode(['status' => 'error', 'message' => 'Unauthorized'])); + echo json_encode(['status' => 'error', 'message' => 'Unauthorized']); + exit; } - // 4) Validate the username format. $userId = $_SESSION['username']; if (!preg_match(REGEX_USER, $userId)) { http_response_code(400); - error_log("totp_saveCode: invalid username format: {$userId}"); - exit(json_encode(['status' => 'error', 'message' => 'Invalid user identifier'])); + echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']); + exit; } - // 5) Delegate to the model. - $result = userModel::saveTOTPRecoveryCode($userId); - if ($result['status'] === 'ok') { + $result = UserModel::saveTOTPRecoveryCode($userId); + if (($result['status'] ?? '') === 'ok') { echo json_encode($result); } else { http_response_code(500); echo json_encode($result); } + exit; } /** @@ -791,43 +742,40 @@ class UserController * ) * ) */ - public function setupTOTP() { - // Allow access if the user is authenticated or pending TOTP. - if (!((isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']))) { + // Allow access if authenticated OR pending TOTP + if (!( (isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']) )) { http_response_code(403); - exit(json_encode(["error" => "Not authorized to access TOTP setup"])); - } - - // Verify CSRF token from headers. - $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"]); + header('Content-Type: application/json'); + echo json_encode(["error" => "Not authorized to access TOTP setup"]); exit; } - $username = $_SESSION['username'] ?? ''; - if (!$username) { + self::requireCsrf(); + + // Fix: if username not present (pending flow), fall back to pending_login_user + $username = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? ''); + if ($username === '') { http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Username not available for TOTP setup']); exit; } - // Set header for PNG output. header("Content-Type: image/png"); + header('X-Content-Type-Options: nosniff'); - // Delegate the TOTP setup work to the model. - $result = userModel::setupTOTP($username); + $result = UserModel::setupTOTP($username); if (isset($result['error'])) { http_response_code(500); + header('Content-Type: application/json'); echo json_encode(["error" => $result['error']]); exit; } - // Output the QR code image. echo $result['imageData']; + exit; } /** @@ -866,11 +814,11 @@ class UserController * ) * ) */ - public function verifyTOTP() { header('Content-Type: application/json'); header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';"); + header('X-Content-Type-Options: nosniff'); // Rate-limit if (!isset($_SESSION['totp_failures'])) { @@ -890,16 +838,10 @@ class UserController } // 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; - } + self::requireCsrf(); // Parse & validate input - $inputData = json_decode(file_get_contents("php://input"), true); + $inputData = self::readJson(); $code = trim($inputData['totp_code'] ?? ''); if (!preg_match('/^\d{6}$/', $code)) { http_response_code(400); @@ -916,11 +858,11 @@ class UserController \RobThree\Auth\Algorithm::Sha1 ); - // === Pending-login flow (we just came from auth and need to finish login) === + // Pending-login flow if (isset($_SESSION['pending_login_user'])) { - $username = $_SESSION['pending_login_user']; + $username = $_SESSION['pending_login_user']; $pendingSecret = $_SESSION['pending_login_secret'] ?? null; - $rememberMe = $_SESSION['pending_login_remember_me'] ?? false; + $rememberMe = $_SESSION['pending_login_remember_me'] ?? false; if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) { $_SESSION['totp_failures']++; @@ -939,13 +881,14 @@ class UserController $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); $all = json_decode($dec, true) ?: []; } + $perms = loadUserPermissions($username); $all[$token] = [ - 'username' => $username, - 'expiry' => $expiry, - 'isAdmin' => ((int)userModel::getUserRole($username) === 1), - 'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false, - 'readOnly' => loadUserPermissions($username)['readOnly'] ?? false, - 'disableUpload' => loadUserPermissions($username)['disableUpload'] ?? false + 'username' => $username, + 'expiry' => $expiry, + 'isAdmin' => ((int)UserModel::getUserRole($username) === 1), + 'folderOnly' => $perms['folderOnly'] ?? false, + 'readOnly' => $perms['readOnly'] ?? false, + 'disableUpload' => $perms['disableUpload'] ?? false ]; file_put_contents( $tokFile, @@ -957,17 +900,16 @@ class UserController setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true); } - // === Finalize login into session exactly as finalizeLogin() would === + // Finalize login session_regenerate_id(true); $_SESSION['authenticated'] = true; $_SESSION['username'] = $username; - $_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1); + $_SESSION['isAdmin'] = ((int)UserModel::getUserRole($username) === 1); $perms = loadUserPermissions($username); $_SESSION['folderOnly'] = $perms['folderOnly'] ?? false; $_SESSION['readOnly'] = $perms['readOnly'] ?? false; $_SESSION['disableUpload'] = $perms['disableUpload'] ?? false; - // Clean up pending markers unset( $_SESSION['pending_login_user'], $_SESSION['pending_login_secret'], @@ -975,7 +917,6 @@ class UserController $_SESSION['totp_failures'] ); - // Send back full login payload echo json_encode([ 'status' => 'ok', 'success' => 'Login successful', @@ -990,13 +931,13 @@ class UserController // Setup/verification flow (not pending) $username = $_SESSION['username'] ?? ''; - if (!$username) { + if ($username === '') { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Username not found in session']); exit; } - $totpSecret = userModel::getTOTPSecret($username); + $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.']); @@ -1010,34 +951,22 @@ class UserController exit; } - // Successful setup/verification unset($_SESSION['totp_failures']); echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']); + exit; } + /** + * Upload profile picture (multipart/form-data) + */ public function uploadPicture() { - header('Content-Type: application/json'); + self::jsonHeaders(); - // 1) Auth check - if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(['success' => false, 'error' => 'Unauthorized']); - exit; - } + // Auth & CSRF + self::requireAuth(); + self::requireCsrf(); - // 2) CSRF check - $headers = function_exists('getallheaders') - ? array_change_key_case(getallheaders(), CASE_LOWER) - : []; - $csrf = $headers['x-csrf-token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; - if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) { - http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); - exit; - } - - // 3) File presence if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'No file uploaded or error']); @@ -1045,7 +974,7 @@ class UserController } $file = $_FILES['profile_picture']; - // 4) Validate MIME & size + // Validate MIME & size $allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $file['tmp_name']); @@ -1061,32 +990,29 @@ class UserController exit; } - // 5) Destination under public/uploads/profile_pics - $uploadDir = UPLOAD_DIR . '/profile_pics'; + // Destination + $uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics'; if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']); exit; } - // 6) Move file $ext = $allowed[$mime]; $user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']); $filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext; - $dest = "$uploadDir/$filename"; + $dest = $uploadDir . '/' . $filename; if (!move_uploaded_file($file['tmp_name'], $dest)) { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to save file']); exit; } - // 7) Build public URL + // Assuming /uploads maps to UPLOAD_DIR publicly $url = '/uploads/profile_pics/' . $filename; - // ─── THIS IS WHERE WE PERSIST INTO users.txt ─── $result = UserModel::setProfilePicture($_SESSION['username'], $url); - if (!$result['success']) { - // on failure, remove the file we just wrote + if (!($result['success'] ?? false)) { @unlink($dest); http_response_code(500); echo json_encode([ @@ -1095,9 +1021,7 @@ class UserController ]); exit; } - // ───────────────────────────────────────────────── - // 8) Return success echo json_encode(['success' => true, 'url' => $url]); exit; } diff --git a/src/models/AdminModel.php b/src/models/AdminModel.php index 509052e..b42df7f 100644 --- a/src/models/AdminModel.php +++ b/src/models/AdminModel.php @@ -6,25 +6,60 @@ require_once PROJECT_ROOT . '/config/config.php'; class AdminModel { /** - * Parse a shorthand size value (e.g. "5G", "500M", "123K") into bytes. + * Parse a shorthand size value (e.g. "5G", "500M", "123K", "50MB", "10KiB") into bytes. + * Accepts bare numbers (bytes) and common suffixes: K, KB, KiB, M, MB, MiB, G, GB, GiB, etc. * * @param string $val - * @return int + * @return int Bytes (rounded) */ private static function parseSize(string $val): int { - $unit = strtolower(substr($val, -1)); - $num = (int) rtrim($val, 'bkmgtpezyBKMGTPESY'); - switch ($unit) { - case 'g': - return $num * 1024 ** 3; - case 'm': - return $num * 1024 ** 2; - case 'k': - return $num * 1024; - default: - return $num; + $val = trim($val); + if ($val === '') { + return 0; } + + // Match: number + optional unit/suffix (K, KB, KiB, M, MB, MiB, G, GB, GiB, ...) + if (preg_match('/^\s*(\d+(?:\.\d+)?)\s*([kmgtpezy]?i?b?)?\s*$/i', $val, $m)) { + $num = (float)$m[1]; + $unit = strtolower($m[2] ?? ''); + + switch ($unit) { + case 'k': case 'kb': case 'kib': + $num *= 1024; + break; + case 'm': case 'mb': case 'mib': + $num *= 1024 ** 2; + break; + case 'g': case 'gb': case 'gib': + $num *= 1024 ** 3; + break; + case 't': case 'tb': case 'tib': + $num *= 1024 ** 4; + break; + case 'p': case 'pb': case 'pib': + $num *= 1024 ** 5; + break; + case 'e': case 'eb': case 'eib': + $num *= 1024 ** 6; + break; + case 'z': case 'zb': case 'zib': + $num *= 1024 ** 7; + break; + case 'y': case 'yb': case 'yib': + $num *= 1024 ** 8; + break; + // case 'b' or empty => bytes; do nothing + default: + // If unit is just 'b' or empty, treat as bytes. + // For unknown units fall back to bytes. + break; + } + return (int) round($num); + } + + // Fallback: cast any unrecognized input to int (bytes) + return (int)$val; } /** @@ -35,17 +70,22 @@ class AdminModel */ public static function updateConfig(array $configUpdate): array { - // New: only enforce OIDC fields when OIDC is enabled + // Ensure encryption key exists + if (empty($GLOBALS['encryptionKey']) || !is_string($GLOBALS['encryptionKey'])) { + return ["error" => "Server encryption key is not configured."]; + } + + // Only enforce OIDC fields when OIDC is enabled $oidcDisabled = isset($configUpdate['loginOptions']['disableOIDCLogin']) - ? (bool)$configUpdate['loginOptions']['disableOIDCLogin'] - : true; // default to disabled when not present + ? (bool)$configUpdate['loginOptions']['disableOIDCLogin'] + : true; // default to disabled when not present if (!$oidcDisabled) { - $oidc = $configUpdate['oidc'] ?? []; - $required = ['providerUrl','clientId','clientSecret','redirectUri']; - foreach ($required as $k) { - if (empty($oidc[$k]) || !is_string($oidc[$k])) { - return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."]; + $oidc = $configUpdate['oidc'] ?? []; + $required = ['providerUrl','clientId','clientSecret','redirectUri']; + foreach ($required as $k) { + if (empty($oidc[$k]) || !is_string($oidc[$k])) { + return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."]; } } } @@ -72,7 +112,7 @@ class AdminModel $configUpdate['sharedMaxUploadSize'] = $sms; } - // ── NEW: normalize authBypass & authHeaderName ───────────────────────── + // Normalize authBypass & authHeaderName if (!isset($configUpdate['loginOptions']['authBypass'])) { $configUpdate['loginOptions']['authBypass'] = false; } @@ -85,10 +125,8 @@ class AdminModel ) { $configUpdate['loginOptions']['authHeaderName'] = 'X-Remote-User'; } else { - $configUpdate['loginOptions']['authHeaderName'] = - trim($configUpdate['loginOptions']['authHeaderName']); + $configUpdate['loginOptions']['authHeaderName'] = trim($configUpdate['loginOptions']['authHeaderName']); } - // ─────────────────────────────────────────────────────────────────────────── // Convert configuration to JSON. $plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT); @@ -109,7 +147,7 @@ class AdminModel if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) { // Attempt a cleanup: delete the old file and try again. if (file_exists($configFile)) { - unlink($configFile); + @unlink($configFile); } if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) { error_log("AdminModel::updateConfig: Failed to write configuration even after deletion."); @@ -130,13 +168,15 @@ class AdminModel public static function getConfig(): array { $configFile = USERS_DIR . 'adminConfig.json'; + if (file_exists($configFile)) { $encryptedContent = file_get_contents($configFile); $decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']); if ($decryptedContent === false) { - http_response_code(500); + // Do not set HTTP status here; let the controller decide. return ["error" => "Failed to decrypt configuration."]; } + $config = json_decode($decryptedContent, true); if (!is_array($config)) { $config = []; @@ -144,7 +184,7 @@ class AdminModel // Normalize login options if missing if (!isset($config['loginOptions'])) { - // migrate legacy top-level flags; default OIDC to true (disabled) + // Migrate legacy top-level flags; default OIDC to true (disabled) $config['loginOptions'] = [ 'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false, 'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false, @@ -152,13 +192,14 @@ class AdminModel ]; unset($config['disableFormLogin'], $config['disableBasicAuth'], $config['disableOIDCLogin']); } else { - // normalize booleans; default OIDC to true (disabled) if missing + // Normalize booleans; default OIDC to true (disabled) if missing $lo = &$config['loginOptions']; $lo['disableFormLogin'] = isset($lo['disableFormLogin']) ? (bool)$lo['disableFormLogin'] : false; $lo['disableBasicAuth'] = isset($lo['disableBasicAuth']) ? (bool)$lo['disableBasicAuth'] : false; $lo['disableOIDCLogin'] = isset($lo['disableOIDCLogin']) ? (bool)$lo['disableOIDCLogin'] : true; } + // Ensure OIDC structure exists if (!isset($config['oidc']) || !is_array($config['oidc'])) { $config['oidc'] = [ 'providerUrl' => '', @@ -174,6 +215,7 @@ class AdminModel } } + // Normalize authBypass & authHeaderName if (!array_key_exists('authBypass', $config['loginOptions'])) { $config['loginOptions']['authBypass'] = false; } else { @@ -191,38 +233,41 @@ class AdminModel if (!isset($config['globalOtpauthUrl'])) { $config['globalOtpauthUrl'] = ""; } - if (!isset($config['header_title']) || empty($config['header_title'])) { + if (!isset($config['header_title']) || $config['header_title'] === '') { $config['header_title'] = "FileRise"; } if (!isset($config['enableWebDAV'])) { $config['enableWebDAV'] = false; } - // Default sharedMaxUploadSize to 50MB or TOTAL_UPLOAD_SIZE if smaller - if (!isset($config['sharedMaxUploadSize'])) { - $defaultSms = min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE)); - $config['sharedMaxUploadSize'] = $defaultSms; + + // sharedMaxUploadSize: default if missing; clamp if present + $maxBytes = self::parseSize(TOTAL_UPLOAD_SIZE); + if (!isset($config['sharedMaxUploadSize']) || !is_numeric($config['sharedMaxUploadSize']) || $config['sharedMaxUploadSize'] < 1) { + $config['sharedMaxUploadSize'] = min(50 * 1024 * 1024, $maxBytes); + } else { + $config['sharedMaxUploadSize'] = (int)min((int)$config['sharedMaxUploadSize'], $maxBytes); } return $config; - } else { - // Return defaults. - return [ - 'header_title' => "FileRise", - 'oidc' => [ - 'providerUrl' => 'https://your-oidc-provider.com', - 'clientId' => '', - 'clientSecret' => '', - 'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback' - ], - 'loginOptions' => [ - 'disableFormLogin' => false, - 'disableBasicAuth' => false, - 'disableOIDCLogin' => true - ], - 'globalOtpauthUrl' => "", - 'enableWebDAV' => false, - 'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE)) - ]; } + + // No config on disk; return defaults. + return [ + 'header_title' => "FileRise", + 'oidc' => [ + 'providerUrl' => 'https://your-oidc-provider.com', + 'clientId' => '', + 'clientSecret' => '', + 'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback' + ], + 'loginOptions' => [ + 'disableFormLogin' => false, + 'disableBasicAuth' => false, + 'disableOIDCLogin' => true + ], + 'globalOtpauthUrl' => "", + 'enableWebDAV' => false, + 'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE)) + ]; } } diff --git a/src/models/FileModel.php b/src/models/FileModel.php index 49ee7ac..707b22f 100644 --- a/src/models/FileModel.php +++ b/src/models/FileModel.php @@ -5,6 +5,42 @@ require_once PROJECT_ROOT . '/config/config.php'; class FileModel { + /** + * Resolve a logical folder key (e.g. "root", "invoices/2025") to a + * real path under UPLOAD_DIR, enforce REGEX_FOLDER_NAME, and ensure + * optional creation. + * + * @param string $folder + * @param bool $create + * @return array [string|null $realPath, string|null $error] + */ + private static function resolveFolderPath(string $folder, bool $create = true): array { + $folder = trim($folder) ?: 'root'; + + if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + return [null, "Invalid folder name."]; + } + + $base = realpath(UPLOAD_DIR); + if ($base === false) { + return [null, "Server misconfiguration."]; + } + + $dir = (strtolower($folder) === 'root') + ? $base + : $base . DIRECTORY_SEPARATOR . trim($folder, "/\\ "); + + if ($create && !is_dir($dir) && !mkdir($dir, 0775, true)) { + return [null, "Cannot create destination folder"]; + } + + $real = realpath($dir); + if ($real === false || strpos($real, $base) !== 0) { + return [null, "Invalid folder path."]; + } + return [$real, null]; + } + /** * Copies files from a source folder to a destination folder, updating metadata if available. * @@ -15,71 +51,76 @@ class FileModel { */ public static function copyFiles($sourceFolder, $destinationFolder, $files) { $errors = []; - $baseDir = rtrim(UPLOAD_DIR, '/\\'); - - // Build source and destination directories. - $sourceDir = ($sourceFolder === 'root') - ? $baseDir . DIRECTORY_SEPARATOR - : $baseDir . DIRECTORY_SEPARATOR . trim($sourceFolder, "/\\ ") . DIRECTORY_SEPARATOR; - $destDir = ($destinationFolder === 'root') - ? $baseDir . DIRECTORY_SEPARATOR - : $baseDir . DIRECTORY_SEPARATOR . trim($destinationFolder, "/\\ ") . DIRECTORY_SEPARATOR; - - // Get metadata file paths. - $srcMetaFile = self::getMetadataFilePath($sourceFolder); + + list($sourceDir, $err) = self::resolveFolderPath($sourceFolder, false); + if ($err) return ["error" => $err]; + list($destDir, $err) = self::resolveFolderPath($destinationFolder, true); + if ($err) return ["error" => $err]; + + $sourceDir .= DIRECTORY_SEPARATOR; + $destDir .= DIRECTORY_SEPARATOR; + + // Metadata paths + $srcMetaFile = self::getMetadataFilePath($sourceFolder); $destMetaFile = self::getMetadataFilePath($destinationFolder); - - $srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : []; - $destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : []; - - // Define a safe file name pattern. + + $srcMetadata = file_exists($srcMetaFile) ? (json_decode(file_get_contents($srcMetaFile), true) ?: []) : []; + $destMetadata = file_exists($destMetaFile) ? (json_decode(file_get_contents($destMetaFile), true) ?: []) : []; + $safeFileNamePattern = REGEX_FILE_NAME; - + $actor = $_SESSION['username'] ?? 'Unknown'; + $now = date(DATE_TIME_FORMAT); + foreach ($files as $fileName) { - // Get the clean file name. $originalName = basename(trim($fileName)); - $basename = $originalName; + $basename = $originalName; + if (!preg_match($safeFileNamePattern, $basename)) { $errors[] = "$basename has an invalid name."; continue; } - - $srcPath = $sourceDir . $originalName; + + $srcPath = $sourceDir . $originalName; $destPath = $destDir . $basename; - + clearstatcache(); if (!file_exists($srcPath)) { $errors[] = "$originalName does not exist in source."; continue; } - - // If a file with the same name exists at the destination, create a unique name. + + // Avoid overwrite: pick unique name if (file_exists($destPath)) { - $uniqueName = self::getUniqueFileName($destDir, $basename); - $basename = $uniqueName; - $destPath = $destDir . $uniqueName; + $basename = self::getUniqueFileName($destDir, $basename); + $destPath = $destDir . $basename; } - + if (!copy($srcPath, $destPath)) { $errors[] = "Failed to copy $basename."; continue; } - - // Update destination metadata if metadata exists in source. - if (isset($srcMetadata[$originalName])) { - $destMetadata[$basename] = $srcMetadata[$originalName]; + + // Carry over non-ownership fields (e.g., tags), but stamp new ownership/timestamps + $tags = []; + if (isset($srcMetadata[$originalName]['tags']) && is_array($srcMetadata[$originalName]['tags'])) { + $tags = $srcMetadata[$originalName]['tags']; } + + $destMetadata[$basename] = [ + 'uploaded' => $now, + 'modified' => $now, + 'uploader' => $actor, + 'tags' => $tags + ]; } - - if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) { + + if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) { $errors[] = "Failed to update destination metadata."; } - - if (empty($errors)) { - return ["success" => "Files copied successfully"]; - } else { - return ["error" => implode("; ", $errors)]; - } + + return empty($errors) + ? ["success" => "Files copied successfully"] + : ["error" => implode("; ", $errors)]; } /** @@ -130,13 +171,11 @@ class FileModel { */ public static function deleteFiles($folder, $files) { $errors = []; - $baseDir = rtrim(UPLOAD_DIR, '/\\'); - - // Determine the upload directory. - $uploadDir = ($folder === 'root') - ? $baseDir . DIRECTORY_SEPARATOR - : $baseDir . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR; - + + list($uploadDir, $err) = self::resolveFolderPath($folder, false); + if ($err) return ["error" => $err]; + $uploadDir .= DIRECTORY_SEPARATOR; + // Setup the Trash folder and metadata. $trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; if (!file_exists($trashDir)) { @@ -149,7 +188,7 @@ class FileModel { if (!is_array($trashData)) { $trashData = []; } - + // Load folder metadata if available. $metadataFile = self::getMetadataFilePath($folder); $folderMetadata = file_exists($metadataFile) @@ -158,27 +197,25 @@ class FileModel { if (!is_array($folderMetadata)) { $folderMetadata = []; } - + $movedFiles = []; - // Define a safe file name pattern. $safeFileNamePattern = REGEX_FILE_NAME; - + foreach ($files as $fileName) { $basename = basename(trim($fileName)); - + // Validate the file name. if (!preg_match($safeFileNamePattern, $basename)) { $errors[] = "$basename has an invalid name."; continue; } - + $filePath = $uploadDir . $basename; - + // Check if file exists. if (file_exists($filePath)) { - // Append a timestamp to create a unique trash file name. - $timestamp = time(); - $trashFileName = $basename . "_" . $timestamp; + // Unique trash name (timestamp + random) + $trashFileName = $basename . '_' . time() . '_' . bin2hex(random_bytes(4)); if (rename($filePath, $trashDir . $trashFileName)) { $movedFiles[] = $basename; // Record trash metadata for possible restoration. @@ -187,11 +224,9 @@ class FileModel { 'originalFolder' => $uploadDir, 'originalName' => $basename, 'trashName' => $trashFileName, - 'trashedAt' => $timestamp, - 'uploaded' => isset($folderMetadata[$basename]['uploaded']) - ? $folderMetadata[$basename]['uploaded'] : "Unknown", - 'uploader' => isset($folderMetadata[$basename]['uploader']) - ? $folderMetadata[$basename]['uploader'] : "Unknown", + 'trashedAt' => time(), + 'uploaded' => $folderMetadata[$basename]['uploaded'] ?? "Unknown", + 'uploader' => $folderMetadata[$basename]['uploader'] ?? "Unknown", 'deletedBy' => $_SESSION['username'] ?? "Unknown" ]; } else { @@ -203,10 +238,10 @@ class FileModel { $movedFiles[] = $basename; } } - + // Save updated trash metadata. - file_put_contents($trashMetadataFile, json_encode($trashData, JSON_PRETTY_PRINT)); - + file_put_contents($trashMetadataFile, json_encode($trashData, JSON_PRETTY_PRINT), LOCK_EX); + // Remove deleted file entries from folder metadata. if (file_exists($metadataFile)) { $metadata = json_decode(file_get_contents($metadataFile), true); @@ -216,10 +251,10 @@ class FileModel { unset($metadata[$delFile]); } } - file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT)); + file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX); } } - + if (empty($errors)) { return ["success" => "Files moved to Trash: " . implode(", ", $movedFiles)]; } else { @@ -227,7 +262,7 @@ class FileModel { } } - /** + /** * Moves files from a source folder to a destination folder and updates metadata. * * @param string $sourceFolder The source folder (e.g., "root" or a subfolder). @@ -237,28 +272,20 @@ class FileModel { */ public static function moveFiles($sourceFolder, $destinationFolder, $files) { $errors = []; - $baseDir = rtrim(UPLOAD_DIR, '/\\'); - - // Build source and destination directories. - $sourceDir = ($sourceFolder === 'root') - ? $baseDir . DIRECTORY_SEPARATOR - : $baseDir . DIRECTORY_SEPARATOR . trim($sourceFolder, "/\\ ") . DIRECTORY_SEPARATOR; - $destDir = ($destinationFolder === 'root') - ? $baseDir . DIRECTORY_SEPARATOR - : $baseDir . DIRECTORY_SEPARATOR . trim($destinationFolder, "/\\ ") . DIRECTORY_SEPARATOR; - - // Ensure destination directory exists. - if (!is_dir($destDir)) { - if (!mkdir($destDir, 0775, true)) { - return ["error" => "Could not create destination folder"]; - } - } - + + list($sourceDir, $err) = self::resolveFolderPath($sourceFolder, false); + if ($err) return ["error" => $err]; + list($destDir, $err) = self::resolveFolderPath($destinationFolder, true); + if ($err) return ["error" => $err]; + + $sourceDir .= DIRECTORY_SEPARATOR; + $destDir .= DIRECTORY_SEPARATOR; + // Get metadata file paths. - $srcMetaFile = self::getMetadataFilePath($sourceFolder); + $srcMetaFile = self::getMetadataFilePath($sourceFolder); $destMetaFile = self::getMetadataFilePath($destinationFolder); - - $srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : []; + + $srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : []; $destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : []; if (!is_array($srcMetadata)) { $srcMetadata = []; @@ -266,43 +293,42 @@ class FileModel { if (!is_array($destMetadata)) { $destMetadata = []; } - + $movedFiles = []; - // Define a safe file name pattern. $safeFileNamePattern = REGEX_FILE_NAME; - + foreach ($files as $fileName) { // Save the original file name for metadata lookup. $originalName = basename(trim($fileName)); $basename = $originalName; - + // Validate the file name. if (!preg_match($safeFileNamePattern, $basename)) { $errors[] = "$basename has invalid characters."; continue; } - + $srcPath = $sourceDir . $originalName; $destPath = $destDir . $basename; - + clearstatcache(); if (!file_exists($srcPath)) { $errors[] = "$originalName does not exist in source."; continue; } - + // If a file with the same name exists in destination, generate a unique name. if (file_exists($destPath)) { $uniqueName = self::getUniqueFileName($destDir, $basename); $basename = $uniqueName; $destPath = $destDir . $uniqueName; } - + if (!rename($srcPath, $destPath)) { $errors[] = "Failed to move $basename."; continue; } - + $movedFiles[] = $originalName; // Update destination metadata: if metadata for the original file exists in source, move it under the new name. if (isset($srcMetadata[$originalName])) { @@ -310,15 +336,15 @@ class FileModel { unset($srcMetadata[$originalName]); } } - + // Write back updated metadata. - if (file_put_contents($srcMetaFile, json_encode($srcMetadata, JSON_PRETTY_PRINT)) === false) { + if (file_put_contents($srcMetaFile, json_encode($srcMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) { $errors[] = "Failed to update source metadata."; } - if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) { + if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) { $errors[] = "Failed to update destination metadata."; } - + if (empty($errors)) { return ["success" => "Files moved successfully"]; } else { @@ -326,7 +352,7 @@ class FileModel { } } - /** + /** * Renames a file within a given folder and updates folder metadata. * * @param string $folder The folder where the file is located (or "root" for the base directory). @@ -335,46 +361,45 @@ class FileModel { * @return array An associative array with either "success" (and newName) or "error" message. */ public static function renameFile($folder, $oldName, $newName) { - // Determine the directory path. - $directory = ($folder !== 'root') - ? rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR - : UPLOAD_DIR; - + list($directory, $err) = self::resolveFolderPath($folder, false); + if ($err) return ["error" => $err]; + $directory .= DIRECTORY_SEPARATOR; + // Sanitize file names. $oldName = basename(trim($oldName)); $newName = basename(trim($newName)); - + // Validate file names using REGEX_FILE_NAME. if (!preg_match(REGEX_FILE_NAME, $oldName) || !preg_match(REGEX_FILE_NAME, $newName)) { return ["error" => "Invalid file name."]; } - + $oldPath = $directory . $oldName; $newPath = $directory . $newName; - + // Helper: Generate a unique file name if the new name already exists. if (file_exists($newPath)) { $newName = self::getUniqueFileName($directory, $newName); $newPath = $directory . $newName; } - + // Check that the old file exists. if (!file_exists($oldPath)) { return ["error" => "File does not exist"]; } - + // Perform the rename. if (rename($oldPath, $newPath)) { // Update the metadata file. $metadataKey = ($folder === 'root') ? "root" : $folder; $metadataFile = META_DIR . str_replace(['/', '\\', ' '], '-', trim($metadataKey)) . '_metadata.json'; - + if (file_exists($metadataFile)) { $metadata = json_decode(file_get_contents($metadataFile), true); if (isset($metadata[$oldName])) { $metadata[$newName] = $metadata[$oldName]; unset($metadata[$oldName]); - file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT)); + file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX); } } return ["success" => "File renamed successfully", "newName" => $newName]; @@ -383,96 +408,87 @@ class FileModel { } } -/* - * Save a file’s contents *and* record its metadata, including who uploaded it. - * - * @param string $folder Folder key (e.g. "root" or "invoices/2025") - * @param string $fileName Basename of the file - * @param resource|string $content File contents (stream or string) - * @param string|null $uploader Username of uploader (if null, falls back to session) - * @return array ["success"=>"…"] or ["error"=>"…"] - */ -public static function saveFile(string $folder, string $fileName, $content, ?string $uploader = null): array { - // Sanitize inputs - $folder = trim($folder) ?: 'root'; - $fileName = basename(trim($fileName)); + /* + * Save a file’s contents *and* record its metadata, including who uploaded it. + * + * @param string $folder Folder key (e.g. "root" or "invoices/2025") + * @param string $fileName Basename of the file + * @param resource|string $content File contents (stream or string) + * @param string|null $uploader Username of uploader (if null, falls back to session) + * @return array ["success"=>"…"] or ["error"=>"…"] + */ + public static function saveFile(string $folder, string $fileName, $content, ?string $uploader = null): array { + $folder = trim($folder) ?: 'root'; + $fileName = basename(trim($fileName)); - // Validate folder name - if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { - return ["error" => "Invalid folder name"]; - } - - // Determine target directory - $baseDir = rtrim(UPLOAD_DIR, '/\\'); - $targetDir = strtolower($folder) === 'root' - ? $baseDir . DIRECTORY_SEPARATOR - : $baseDir . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR; - - // Security check - if (strpos(realpath($targetDir), realpath($baseDir)) !== 0) { - return ["error" => "Invalid folder path"]; - } - - // Ensure directory exists - if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) { - return ["error" => "Failed to create destination folder"]; - } - - $filePath = $targetDir . $fileName; - - // ——— STREAM TO DISK ——— - if (is_resource($content)) { - $out = fopen($filePath, 'wb'); - if ($out === false) { - return ["error" => "Unable to open file for writing"]; + if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + return ["error" => "Invalid folder name"]; } - stream_copy_to_stream($content, $out); - fclose($out); - } else { - if (file_put_contents($filePath, (string)$content) === false) { - return ["error" => "Error saving file"]; + if (!preg_match(REGEX_FILE_NAME, $fileName)) { + return ["error" => "Invalid file name"]; } - } - // ——— UPDATE METADATA ——— - $metadataKey = strtolower($folder) === "root" ? "root" : $folder; - $metadataFileName = str_replace(['/', '\\', ' '], '-', trim($metadataKey)) . '_metadata.json'; - $metadataFilePath = META_DIR . $metadataFileName; - - // Load existing metadata - $metadata = []; - if (file_exists($metadataFilePath)) { - $existing = @json_decode(file_get_contents($metadataFilePath), true); - if (is_array($existing)) { - $metadata = $existing; + $baseDirReal = realpath(UPLOAD_DIR); + if ($baseDirReal === false) { + return ["error" => "Server misconfiguration"]; } + + $targetDir = (strtolower($folder) === 'root') + ? rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR + : rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR; + + // Ensure directory exists *before* realpath + containment check + if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) { + return ["error" => "Failed to create destination folder"]; + } + + $targetDirReal = realpath($targetDir); + if ($targetDirReal === false || strpos($targetDirReal, $baseDirReal) !== 0) { + return ["error" => "Invalid folder path"]; + } + + $filePath = $targetDirReal . DIRECTORY_SEPARATOR . $fileName; + + if (is_resource($content)) { + $out = fopen($filePath, 'wb'); + if ($out === false) return ["error" => "Unable to open file for writing"]; + stream_copy_to_stream($content, $out); + fclose($out); + } else { + if (file_put_contents($filePath, (string)$content, LOCK_EX) === false) { + return ["error" => "Error saving file"]; + } + } + + // Metadata + $metadataKey = strtolower($folder) === "root" ? "root" : $folder; + $metadataFileName = str_replace(['/', '\\', ' '], '-', trim($metadataKey)) . '_metadata.json'; + $metadataFilePath = META_DIR . $metadataFileName; + + $metadata = file_exists($metadataFilePath) ? (json_decode(file_get_contents($metadataFilePath), true) ?: []) : []; + + $currentTime = date(DATE_TIME_FORMAT); + $uploader = $uploader ?? ($_SESSION['username'] ?? "Unknown"); + + if (isset($metadata[$fileName])) { + $metadata[$fileName]['modified'] = $currentTime; + $metadata[$fileName]['uploader'] = $uploader; + } else { + $metadata[$fileName] = [ + "uploaded" => $currentTime, + "modified" => $currentTime, + "uploader" => $uploader + ]; + } + + if (file_put_contents($metadataFilePath, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX) === false) { + return ["error" => "Failed to update metadata"]; + } + + return ["success" => "File saved successfully"]; } - $currentTime = date(DATE_TIME_FORMAT); - // Use passed-in uploader, or fall back to session - if ($uploader === null) { - $uploader = $_SESSION['username'] ?? "Unknown"; - } - - if (isset($metadata[$fileName])) { - $metadata[$fileName]['modified'] = $currentTime; - $metadata[$fileName]['uploader'] = $uploader; - } else { - $metadata[$fileName] = [ - "uploaded" => $currentTime, - "modified" => $currentTime, - "uploader" => $uploader - ]; - } - - if (file_put_contents($metadataFilePath, json_encode($metadata, JSON_PRETTY_PRINT)) === false) { - return ["error" => "Failed to update metadata"]; - } - - return ["success" => "File saved successfully"]; -} - - /** + /** * Validates and retrieves information needed to download a file. * * @param string $folder The folder from which to download (e.g., "root" or a subfolder). @@ -486,13 +502,13 @@ public static function saveFile(string $folder, string $fileName, $content, ?str if (!preg_match(REGEX_FILE_NAME, $file)) { return ["error" => "Invalid file name."]; } - + // Determine the real upload directory. $uploadDirReal = realpath(UPLOAD_DIR); if ($uploadDirReal === false) { return ["error" => "Server misconfiguration."]; } - + // Determine directory based on folder. if (strtolower($folder) === 'root' || trim($folder) === '') { $directory = $uploadDirReal; @@ -507,11 +523,11 @@ public static function saveFile(string $folder, string $fileName, $content, ?str return ["error" => "Invalid folder path."]; } } - + // Build the file path. $filePath = $directory . DIRECTORY_SEPARATOR . $file; $realFilePath = realpath($filePath); - + // Ensure the file exists and is within the allowed directory. if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) { return ["error" => "Access forbidden."]; @@ -519,16 +535,20 @@ public static function saveFile(string $folder, string $fileName, $content, ?str if (!file_exists($realFilePath)) { return ["error" => "File not found."]; } - - // Get the MIME type. - $mimeType = mime_content_type($realFilePath); + + // Get the MIME type with safe fallback. + $mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null; + if (!$mimeType) { + $mimeType = 'application/octet-stream'; + } + return [ "filePath" => $realFilePath, "mimeType" => $mimeType ]; } - /** + /** * Creates a ZIP archive of the specified files from a given folder. * * @param string $folder The folder from which to zip the files (e.g., "root" or a subfolder). @@ -555,7 +575,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str return ["error" => "Folder not found."]; } } - + // Validate each file and build an array of files to zip. $filesToZip = []; foreach ($files as $fileName) { @@ -572,12 +592,12 @@ public static function saveFile(string $folder, string $fileName, $content, ?str if (empty($filesToZip)) { return ["error" => "No valid files found to zip."]; } - + // Create a temporary ZIP file. $tempZip = tempnam(sys_get_temp_dir(), 'zip'); unlink($tempZip); // Remove the temp file so that ZipArchive can create a new file. $tempZip .= '.zip'; - + $zip = new ZipArchive(); if ($zip->open($tempZip, ZipArchive::CREATE) !== TRUE) { return ["error" => "Could not create zip archive."]; @@ -587,11 +607,11 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $zip->addFile($filePath, basename($filePath)); } $zip->close(); - + return ["zipPath" => $tempZip]; } - /** + /** * Extracts ZIP archives from the specified folder. * * @param string $folder The folder from which ZIP files will be extracted (e.g., "root" or a subfolder). @@ -602,110 +622,125 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $errors = []; $allSuccess = true; $extractedFiles = []; - - // Determine the base upload directory and build the folder path. + $baseDir = realpath(UPLOAD_DIR); if ($baseDir === false) { return ["error" => "Uploads directory not configured correctly."]; } - - if (strtolower($folder) === "root" || trim($folder) === "") { + + // Build target dir + if (strtolower(trim($folder) ?: '') === "root") { $relativePath = ""; } else { $parts = explode('/', trim($folder, "/\\")); foreach ($parts as $part) { - if (empty($part) || $part === '.' || $part === '..' || !preg_match(REGEX_FOLDER_NAME, $part)) { + if ($part === '' || $part === '.' || $part === '..' || !preg_match(REGEX_FOLDER_NAME, $part)) { return ["error" => "Invalid folder name."]; } } $relativePath = implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR; } - - $folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath; + + $folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath; + if (!is_dir($folderPath) && !mkdir($folderPath, 0775, true)) { + return ["error" => "Folder not found and cannot be created."]; + } $folderPathReal = realpath($folderPath); if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) { return ["error" => "Folder not found."]; } - - // Prepare metadata. - // Reuse our helper method if available; otherwise, re-create the logic. - $metadataFile = self::getMetadataFilePath($folder); - $srcMetadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : []; - if (!is_array($srcMetadata)) { - $srcMetadata = []; - } - // For simplicity, we update the same metadata file after extraction. - $destMetadata = $srcMetadata; - - // Define a safe file name pattern. + + // Prepare metadata container + $metadataFile = self::getMetadataFilePath($folder); + $destMetadata = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : []; + $safeFileNamePattern = REGEX_FILE_NAME; - - // Process each ZIP file. + $actor = $_SESSION['username'] ?? 'Unknown'; + $now = date(DATE_TIME_FORMAT); + foreach ($files as $zipFileName) { - $originalName = basename(trim($zipFileName)); - // Process only .zip files. - if (strtolower(substr($originalName, -4)) !== '.zip') { + $zipBase = basename(trim($zipFileName)); + if (strtolower(substr($zipBase, -4)) !== '.zip') { continue; } - if (!preg_match($safeFileNamePattern, $originalName)) { - $errors[] = "$originalName has an invalid name."; + if (!preg_match($safeFileNamePattern, $zipBase)) { + $errors[] = "$zipBase has an invalid name."; $allSuccess = false; continue; } - - $zipFilePath = $folderPathReal . DIRECTORY_SEPARATOR . $originalName; + + $zipFilePath = $folderPathReal . DIRECTORY_SEPARATOR . $zipBase; if (!file_exists($zipFilePath)) { - $errors[] = "$originalName does not exist in folder."; + $errors[] = "$zipBase does not exist in folder."; $allSuccess = false; continue; } - + $zip = new ZipArchive(); if ($zip->open($zipFilePath) !== TRUE) { - $errors[] = "Could not open $originalName as a zip file."; + $errors[] = "Could not open $zipBase as a zip file."; $allSuccess = false; continue; } - - // Attempt extraction. - if (!$zip->extractTo($folderPathReal)) { - $errors[] = "Failed to extract $originalName."; + + // Minimal Zip Slip guard: fail if any entry looks unsafe + $unsafe = false; + for ($i = 0; $i < $zip->numFiles; $i++) { + $entryName = $zip->getNameIndex($i); + if ($entryName === false) { $unsafe = true; break; } + // Absolute paths, parent traversal, or Windows drive paths + if (strpos($entryName, '../') !== false || strpos($entryName, '..\\') !== false || + str_starts_with($entryName, '/') || preg_match('/^[A-Za-z]:[\\\\\\/]/', $entryName)) { + $unsafe = true; break; + } + } + if ($unsafe) { + $zip->close(); + $errors[] = "$zipBase contains unsafe paths; extraction aborted."; $allSuccess = false; - } else { - // Collect extracted file names from this archive. - for ($i = 0; $i < $zip->numFiles; $i++) { - $entryName = $zip->getNameIndex($i); - $extractedFileName = basename($entryName); - if ($extractedFileName) { - $extractedFiles[] = $extractedFileName; - } - } - // Update metadata for each extracted file if the ZIP has metadata. - if (isset($srcMetadata[$originalName])) { - $zipMeta = $srcMetadata[$originalName]; - for ($i = 0; $i < $zip->numFiles; $i++) { - $entryName = $zip->getNameIndex($i); - $extractedFileName = basename($entryName); - if ($extractedFileName) { - $destMetadata[$extractedFileName] = $zipMeta; - } - } - } + continue; + } + + // Extract safely (whole archive) after precheck + if (!$zip->extractTo($folderPathReal)) { + $errors[] = "Failed to extract $zipBase."; + $allSuccess = false; + $zip->close(); + continue; + } + + // Stamp metadata for extracted regular files + for ($i = 0; $i < $zip->numFiles; $i++) { + $entryName = $zip->getNameIndex($i); + if ($entryName === false) continue; + + $basename = basename($entryName); + if ($basename === '' || !preg_match($safeFileNamePattern, $basename)) continue; + + // Only stamp files that actually exist after extraction + $target = $folderPathReal . DIRECTORY_SEPARATOR . $entryName; + $isDir = str_ends_with($entryName, '/') || is_dir($target); + if ($isDir) continue; + + $extractedFiles[] = $basename; + $destMetadata[$basename] = [ + 'uploaded' => $now, + 'modified' => $now, + 'uploader' => $actor, + // no tags by default + ]; } $zip->close(); } - - // Save updated metadata. - if (file_put_contents($metadataFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) { + + if (file_put_contents($metadataFile, json_encode($destMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) { $errors[] = "Failed to update metadata."; $allSuccess = false; } - - if ($allSuccess) { - return ["success" => true, "extractedFiles" => $extractedFiles]; - } else { - return ["success" => false, "error" => implode(" ", $errors)]; - } + + return $allSuccess + ? ["success" => true, "extractedFiles" => $extractedFiles] + : ["success" => false, "error" => implode(" ", $errors)]; } /** @@ -726,12 +761,12 @@ public static function saveFile(string $folder, string $fileName, $content, ?str return $shareLinks[$token]; } - /** + /** * Creates a share link for a file. * * @param string $folder The folder containing the shared file (or "root"). * @param string $file The name of the file being shared. - * @param int $expirationMinutes The number of minutes until expiration. + * @param int $expirationSeconds The number of seconds until expiration. * @param string $password Optional password protecting the share. * @return array Returns an associative array with keys "token" and "expires" on success, * or "error" on failure. @@ -741,16 +776,21 @@ public static function saveFile(string $folder, string $fileName, $content, ?str if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { return ["error" => "Invalid folder name."]; } - + // Validate file name. + $file = basename(trim($file)); + if (!preg_match(REGEX_FILE_NAME, $file)) { + return ["error" => "Invalid file name."]; + } + // Generate a secure token (32 hex characters). $token = bin2hex(random_bytes(16)); - + // Calculate expiration (Unix timestamp). $expires = time() + $expirationSeconds; - + // Hash the password if provided. $hashedPassword = !empty($password) ? password_hash($password, PASSWORD_DEFAULT) : ""; - + // File to store share links. $shareFile = META_DIR . "share_links.json"; $shareLinks = []; @@ -761,7 +801,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $shareLinks = []; } } - + // Clean up expired share links. $currentTime = time(); foreach ($shareLinks as $key => $link) { @@ -769,7 +809,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str unset($shareLinks[$key]); } } - + // Add new share record. $shareLinks[$token] = [ "folder" => $folder, @@ -777,16 +817,16 @@ public static function saveFile(string $folder, string $fileName, $content, ?str "expires" => $expires, "password" => $hashedPassword ]; - + // Save the updated share links. - if (file_put_contents($shareFile, json_encode($shareLinks, JSON_PRETTY_PRINT))) { + if (file_put_contents($shareFile, json_encode($shareLinks, JSON_PRETTY_PRINT), LOCK_EX)) { return ["token" => $token, "expires" => $expires]; } else { return ["error" => "Could not save share link."]; } } - /** + /** * Retrieves and enriches trash records from the trash metadata file. * * @return array An array of trash items. @@ -802,7 +842,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $trashItems = []; } } - + // Enrich each trash record. foreach ($trashItems as &$item) { if (empty($item['deletedBy'])) { @@ -834,7 +874,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str return $trashItems; } - /** + /** * Restores files from Trash based on an array of trash file identifiers. * * @param array $trashFiles An array of trash file names (i.e. the 'trashName' fields). @@ -844,7 +884,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str public static function restoreFiles(array $trashFiles) { $errors = []; $restoredItems = []; - + // Setup Trash directory and trash metadata file. $trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; if (!file_exists($trashDir)) { @@ -859,7 +899,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $trashData = []; } } - + // Helper to get metadata file path for a folder. $getMetadataFilePath = function($folder) { if (strtolower($folder) === 'root' || trim($folder) === '') { @@ -867,7 +907,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str } return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json'; }; - + // Process each provided trash file name. foreach ($trashFiles as $trashFileName) { $trashFileName = trim($trashFileName); @@ -876,7 +916,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $errors[] = "$trashFileName has an invalid format."; continue; } - + // Locate the matching trash record. $recordKey = null; foreach ($trashData as $key => $record) { @@ -889,7 +929,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $errors[] = "No trash record found for $trashFileName."; continue; } - + $record = $trashData[$recordKey]; if (!isset($record['originalFolder']) || !isset($record['originalName'])) { $errors[] = "Incomplete trash record for $trashFileName."; @@ -897,7 +937,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str } $originalFolder = $record['originalFolder']; $originalName = $record['originalName']; - + // Convert absolute original folder to relative folder. $relativeFolder = 'root'; if (strpos($originalFolder, UPLOAD_DIR) === 0) { @@ -906,12 +946,12 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $relativeFolder = 'root'; } } - + // Build destination path. $destinationPath = (strtolower($relativeFolder) !== 'root') ? rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $relativeFolder . DIRECTORY_SEPARATOR . $originalName : rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $originalName; - + // Handle folder-type records if necessary. if (isset($record['type']) && $record['type'] === 'folder') { if (!file_exists($destinationPath)) { @@ -928,7 +968,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str unset($trashData[$recordKey]); continue; } - + // For files: Ensure destination directory exists. $destinationDir = dirname($destinationPath); if (!file_exists($destinationDir)) { @@ -937,18 +977,18 @@ public static function saveFile(string $folder, string $fileName, $content, ?str continue; } } - + if (file_exists($destinationPath)) { $errors[] = "File already exists at destination: $originalName."; continue; } - + // Move the file from trash to its original location. $sourcePath = $trashDir . $trashFileName; if (file_exists($sourcePath)) { if (rename($sourcePath, $destinationPath)) { $restoredItems[] = $originalName; - + // Update metadata: Restore metadata for this file. $metadataFile = $getMetadataFilePath($relativeFolder); $metadata = []; @@ -963,7 +1003,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str "uploader" => isset($record['uploader']) ? $record['uploader'] : "Unknown" ]; $metadata[$originalName] = $restoredMeta; - file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT)); + file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX); unset($trashData[$recordKey]); } else { $errors[] = "Failed to restore $originalName."; @@ -972,10 +1012,10 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $errors[] = "Trash file not found: $trashFileName."; } } - + // Write back updated trash metadata. - file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT)); - + file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT), LOCK_EX); + if (empty($errors)) { return ["success" => "Items restored: " . implode(", ", $restoredItems), "restored" => $restoredItems]; } else { @@ -983,7 +1023,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str } } - /** + /** * Deletes trash items based on an array of trash file identifiers. * * @param array $filesToDelete An array of trash file names (identifiers). @@ -1045,7 +1085,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str } // Save the updated trash metadata back as an indexed array. - file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT)); + file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT), LOCK_EX); if (empty($errors)) { return ["deleted" => $deletedFiles]; @@ -1054,37 +1094,37 @@ public static function saveFile(string $folder, string $fileName, $content, ?str } } - /** + /** * Retrieves file tags from the createdTags.json metadata file. * * @return array An array of tags. Returns an empty array if the file doesn't exist or is not readable. */ public static function getFileTags(): array { $metadataPath = META_DIR . 'createdTags.json'; - + // Check if the metadata file exists and is readable. if (!file_exists($metadataPath) || !is_readable($metadataPath)) { error_log('Metadata file does not exist or is not readable: ' . $metadataPath); return []; } - + $data = file_get_contents($metadataPath); if ($data === false) { error_log('Failed to read metadata file: ' . $metadataPath); // Return an empty array for a graceful fallback. return []; } - + $jsonData = json_decode($data, true); if (json_last_error() !== JSON_ERROR_NONE) { error_log('Invalid JSON in metadata file: ' . $metadataPath . ' Error: ' . json_last_error_msg()); return []; } - + return $jsonData; } - /** + /** * Saves tag data for a specified file and updates the global tags. * * @param string $folder The folder where the file is located (e.g., "root" or a subfolder). @@ -1095,31 +1135,37 @@ public static function saveFile(string $folder, string $fileName, $content, ?str * @return array Returns an associative array with a "success" key and updated "globalTags", or an "error" key on failure. */ public static function saveFileTag(string $folder, string $file, array $tags, bool $deleteGlobal = false, ?string $tagToDelete = null): array { - // Determine the folder metadata file. + // Validate the file name and folder $folder = trim($folder) ?: 'root'; - $metadataFile = ""; - if (strtolower($folder) === "root") { - $metadataFile = META_DIR . "root_metadata.json"; - } else { - $metadataFile = META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json'; + $file = basename(trim($file)); + if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + return ["error" => "Invalid folder name."]; } - + if (!preg_match(REGEX_FILE_NAME, $file)) { + return ["error" => "Invalid file name."]; + } + + // Determine the folder metadata file. + $metadataFile = (strtolower($folder) === "root") + ? META_DIR . "root_metadata.json" + : META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json'; + // Load existing metadata for this folder. $metadata = []; if (file_exists($metadataFile)) { $metadata = json_decode(file_get_contents($metadataFile), true) ?? []; } - + // Update the metadata for the specified file. if (!isset($metadata[$file])) { $metadata[$file] = []; } $metadata[$file]['tags'] = $tags; - - if (file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT)) === false) { + + if (file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX) === false) { return ["error" => "Failed to save tag data for file metadata."]; } - + // Now update the global tags file. $globalTagsFile = META_DIR . "createdTags.json"; $globalTags = []; @@ -1129,7 +1175,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $globalTags = []; } } - + // If deleteGlobal is true and tagToDelete is provided, remove that tag. if ($deleteGlobal && !empty($tagToDelete)) { $tagToDeleteLower = strtolower($tagToDelete); @@ -1151,16 +1197,17 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $globalTags[] = $tag; } } + unset($globalTag); } - - if (file_put_contents($globalTagsFile, json_encode($globalTags, JSON_PRETTY_PRINT)) === false) { + + if (file_put_contents($globalTagsFile, json_encode($globalTags, JSON_PRETTY_PRINT), LOCK_EX) === false) { return ["error" => "Failed to save global tags."]; } - + return ["success" => "Tag data saved successfully.", "globalTags" => $globalTags]; } - /** + /** * Retrieves the list of files in a given folder, enriched with metadata, along with global tags. * * @param string $folder The folder name (e.g., "root" or a subfolder). @@ -1170,21 +1217,21 @@ public static function saveFile(string $folder, string $fileName, $content, ?str // --- caps for safe inlining --- if (!defined('LISTING_CONTENT_BYTES_MAX')) define('LISTING_CONTENT_BYTES_MAX', 8192); // 8 KB snippet if (!defined('INDEX_TEXT_BYTES_MAX')) define('INDEX_TEXT_BYTES_MAX', 5 * 1024 * 1024); // only sample files ≤ 5 MB - + $folder = trim($folder) ?: 'root'; - + // Determine the target directory. if (strtolower($folder) !== 'root') { $directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder; } else { $directory = UPLOAD_DIR; } - + // Validate folder. if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { return ["error" => "Invalid folder name."]; } - + // Helper: Build the metadata file path. $getMetadataFilePath = function(string $folder): string { if (strtolower($folder) === 'root' || trim($folder) === '') { @@ -1194,25 +1241,25 @@ public static function saveFile(string $folder, string $fileName, $content, ?str }; $metadataFile = $getMetadataFilePath($folder); $metadata = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : []; - + if (!is_dir($directory)) { return ["error" => "Directory not found."]; } - + $allFiles = array_values(array_diff(scandir($directory), array('.', '..'))); $fileList = []; - + // Define a safe file name pattern. $safeFileNamePattern = REGEX_FILE_NAME; - + // Prepare finfo (if available) for MIME sniffing. $finfo = function_exists('finfo_open') ? @finfo_open(FILEINFO_MIME_TYPE) : false; - + foreach ($allFiles as $file) { if ($file === '' || $file[0] === '.') { continue; // Skip hidden/invalid entries. } - + $filePath = $directory . DIRECTORY_SEPARATOR . $file; if (!is_file($filePath)) { continue; // Only process files. @@ -1220,14 +1267,14 @@ public static function saveFile(string $folder, string $fileName, $content, ?str if (!preg_match($safeFileNamePattern, $file)) { continue; } - + // Meta $mtime = @filemtime($filePath); $fileDateModified = $mtime ? date(DATE_TIME_FORMAT, $mtime) : "Unknown"; $metaKey = $file; $fileUploadedDate = isset($metadata[$metaKey]["uploaded"]) ? $metadata[$metaKey]["uploaded"] : "Unknown"; $fileUploader = isset($metadata[$metaKey]["uploader"]) ? $metadata[$metaKey]["uploader"] : "Unknown"; - + // Size $fileSizeBytes = @filesize($filePath); if (!is_int($fileSizeBytes)) $fileSizeBytes = 0; @@ -1240,7 +1287,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str } else { $fileSizeFormatted = sprintf("%s bytes", number_format($fileSizeBytes)); } - + // MIME + text detection (fallback to extension) $mime = 'application/octet-stream'; if ($finfo) { @@ -1250,7 +1297,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $isTextByMime = (strpos((string)$mime, 'text/') === 0) || $mime === 'application/json' || $mime === 'application/xml'; $isTextByExt = (bool)preg_match('/\.(txt|md|csv|json|xml|html?|css|js|log|ini|conf|config|yml|yaml|php|py|rb|sh|bat|ps1|ts|tsx|c|cpp|h|hpp|java|go|rs)$/i', $file); $isText = $isTextByMime || $isTextByExt; - + // Build entry $fileEntry = [ 'name' => $file, @@ -1262,11 +1309,11 @@ public static function saveFile(string $folder, string $fileName, $content, ?str 'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : [], 'mime' => $mime, ]; - + // Small, safe snippet for text files only (never full content) $fileEntry['content'] = ''; $fileEntry['contentTruncated'] = false; - + if ($isText && $fileSizeBytes > 0) { if ($fileSizeBytes <= INDEX_TEXT_BYTES_MAX) { $fh = @fopen($filePath, 'rb'); @@ -1289,16 +1336,16 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $fileEntry['contentTruncated'] = true; } } - + $fileList[] = $fileEntry; } - + if ($finfo) { @finfo_close($finfo); } - + // Load global tags. $globalTagsFile = META_DIR . "createdTags.json"; $globalTags = file_exists($globalTagsFile) ? (json_decode(file_get_contents($globalTagsFile), true) ?: []) : []; - + return ["files" => $fileList, "globalTags" => $globalTags]; } @@ -1323,7 +1370,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str return false; } unset($links[$token]); - file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)); + file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX); return true; } @@ -1338,21 +1385,18 @@ public static function saveFile(string $folder, string $fileName, $content, ?str public static function createFile(string $folder, string $filename, string $uploader): array { // 1) basic validation - if (!preg_match('/^[\w\-. ]+$/', $filename)) { + $filename = basename(trim($filename)); + if (!preg_match(REGEX_FILE_NAME, $filename)) { return ['success'=>false,'error'=>'Invalid filename','code'=>400]; } - // 2) build target path - $base = UPLOAD_DIR; - if ($folder !== 'root') { - $base = rtrim(UPLOAD_DIR, '/\\') - . DIRECTORY_SEPARATOR . $folder - . DIRECTORY_SEPARATOR; + // 2) resolve target folder + list($baseDir, $err) = self::resolveFolderPath($folder, true); + if ($err) { + return ['success'=>false, 'error'=>$err, 'code'=>($err === 'Invalid folder name.' ? 400 : 500)]; } - if (!is_dir($base) && !mkdir($base, 0775, true)) { - return ['success'=>false,'error'=>'Cannot create folder','code'=>500]; - } - $path = $base . $filename; + + $path = $baseDir . DIRECTORY_SEPARATOR . $filename; // 3) no overwrite if (file_exists($path)) { @@ -1360,12 +1404,12 @@ public static function saveFile(string $folder, string $fileName, $content, ?str } // 4) touch the file - if (false === @file_put_contents($path, '')) { + if (false === @file_put_contents($path, '', LOCK_EX)) { return ['success'=>false,'error'=>'Could not create file','code'=>500]; } // 5) write metadata - $metaKey = ($folder === 'root') ? 'root' : $folder; + $metaKey = (strtolower($folder) === 'root' || trim($folder) === '') ? 'root' : $folder; $metaName = str_replace(['/', '\\', ' '], '-', $metaKey) . '_metadata.json'; $metaPath = META_DIR . $metaName; @@ -1375,12 +1419,14 @@ public static function saveFile(string $folder, string $fileName, $content, ?str $collection = json_decode($json, true) ?: []; } + $now = date(DATE_TIME_FORMAT); $collection[$filename] = [ - 'uploaded' => date(DATE_TIME_FORMAT), + 'uploaded' => $now, + 'modified' => $now, 'uploader' => $uploader ]; - if (false === file_put_contents($metaPath, json_encode($collection, JSON_PRETTY_PRINT))) { + if (false === file_put_contents($metaPath, json_encode($collection, JSON_PRETTY_PRINT), LOCK_EX)) { return ['success'=>false,'error'=>'Failed to update metadata','code'=>500]; } diff --git a/src/models/FolderModel.php b/src/models/FolderModel.php index 5689df1..e04a724 100644 --- a/src/models/FolderModel.php +++ b/src/models/FolderModel.php @@ -6,58 +6,61 @@ require_once PROJECT_ROOT . '/config/config.php'; class FolderModel { /** - * Creates a folder under the specified parent (or in root) and creates an empty metadata file. + * Resolve a (possibly nested) relative folder like "invoices/2025" to a real path + * under UPLOAD_DIR. Validates each path segment against REGEX_FOLDER_NAME, enforces + * containment, and (optionally) creates the folder. * - * @param string $folderName The name of the folder to create. - * @param string $parent (Optional) The parent folder name. Defaults to empty. - * @return array Returns an array with a "success" key if the folder was created, - * or an "error" key if an error occurred. + * @param string $folder Relative folder or "root" + * @param bool $create Create the folder if missing + * @return array [string|null $realPath, string $relative, string|null $error] */ - public static function createFolder(string $folderName, string $parent = ""): array + private static function resolveFolderPath(string $folder, bool $create = false): array { - $folderName = trim($folderName); - $parent = trim($parent); + $folder = trim($folder) ?: 'root'; + $relative = 'root'; - // Validate folder name (only letters, numbers, underscores, dashes, and spaces allowed). - if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { - return ["error" => "Invalid folder name."]; - } - if ($parent !== "" && !preg_match(REGEX_FOLDER_NAME, $parent)) { - return ["error" => "Invalid parent folder name."]; + $base = realpath(UPLOAD_DIR); + if ($base === false) { + return [null, 'root', "Uploads directory not configured correctly."]; } - $baseDir = rtrim(UPLOAD_DIR, '/\\'); - if ($parent !== "" && strtolower($parent) !== "root") { - $fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName; - $relativePath = $parent . "/" . $folderName; + if (strtolower($folder) === 'root') { + $dir = $base; } else { - $fullPath = $baseDir . DIRECTORY_SEPARATOR . $folderName; - $relativePath = $folderName; - } - - // Check if the folder already exists. - if (file_exists($fullPath)) { - return ["error" => "Folder already exists."]; - } - - // Attempt to create the folder. - if (mkdir($fullPath, 0755, true)) { - // Create an empty metadata file for the new folder. - $metadataFile = self::getMetadataFilePath($relativePath); - if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT)) === false) { - return ["error" => "Folder created but failed to create metadata file."]; + // validate each segment against REGEX_FOLDER_NAME + $parts = array_filter(explode('/', trim($folder, "/\\ ")), fn($p) => $p !== ''); + if (empty($parts)) { + return [null, 'root', "Invalid folder name."]; } - return ["success" => true]; - } else { - return ["error" => "Failed to create folder."]; + foreach ($parts as $seg) { + if (!preg_match(REGEX_FOLDER_NAME, $seg)) { + return [null, 'root', "Invalid folder name."]; + } + } + $relative = implode('/', $parts); + $dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts); } + + if (!is_dir($dir)) { + if ($create) { + if (!mkdir($dir, 0775, true)) { + return [null, $relative, "Failed to create folder."]; + } + } else { + return [null, $relative, "Folder does not exist."]; + } + } + + $real = realpath($dir); + if ($real === false || strpos($real, $base) !== 0) { + return [null, $relative, "Invalid folder path."]; + } + + return [$real, $relative, null]; } /** - * Generates the metadata file path for a given folder. - * - * @param string $folder The relative folder path. - * @return string The metadata file path. + * Build metadata file path for a given (relative) folder. */ private static function getMetadataFilePath(string $folder): string { @@ -67,134 +70,146 @@ class FolderModel return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json'; } + /** + * Creates a folder under the specified parent (or in root) and creates an empty metadata file. + */ + public static function createFolder(string $folderName, string $parent = ""): array + { + $folderName = trim($folderName); + $parent = trim($parent); + + if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { + return ["error" => "Invalid folder name."]; + } + + // Resolve parent path (root ok; nested ok) + [$parentReal, $parentRel, $err] = self::resolveFolderPath($parent === '' ? 'root' : $parent, true); + if ($err) return ["error" => $err]; + + $targetRel = ($parentRel === 'root') ? $folderName : ($parentRel . '/' . $folderName); + $targetDir = $parentReal . DIRECTORY_SEPARATOR . $folderName; + + if (file_exists($targetDir)) { + return ["error" => "Folder already exists."]; + } + + if (!mkdir($targetDir, 0775, true)) { + return ["error" => "Failed to create folder."]; + } + + // Create an empty metadata file for the new folder. + $metadataFile = self::getMetadataFilePath($targetRel); + if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT), LOCK_EX) === false) { + return ["error" => "Folder created but failed to create metadata file."]; + } + + return ["success" => true]; + } + /** * Deletes a folder if it is empty and removes its corresponding metadata. - * - * @param string $folder The folder name (relative to the upload directory). - * @return array An associative array with "success" on success or "error" on failure. */ public static function deleteFolder(string $folder): array { - // Prevent deletion of "root". if (strtolower($folder) === 'root') { return ["error" => "Cannot delete root folder."]; } - // Validate folder name. - if (!preg_match(REGEX_FOLDER_NAME, $folder)) { - return ["error" => "Invalid folder name."]; - } + [$real, $relative, $err] = self::resolveFolderPath($folder, false); + if ($err) return ["error" => $err]; - // Build the full folder path. - $baseDir = rtrim(UPLOAD_DIR, '/\\'); - $folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder; - - // Check if the folder exists and is a directory. - if (!file_exists($folderPath) || !is_dir($folderPath)) { - return ["error" => "Folder does not exist."]; - } - - // Prevent deletion if the folder is not empty. - $items = array_diff(scandir($folderPath), array('.', '..')); + // Prevent deletion if not empty. + $items = array_diff(scandir($real), array('.', '..')); if (count($items) > 0) { return ["error" => "Folder is not empty."]; } - // Attempt to delete the folder. - if (rmdir($folderPath)) { - // Remove corresponding metadata file. - $metadataFile = self::getMetadataFilePath($folder); - if (file_exists($metadataFile)) { - unlink($metadataFile); - } - return ["success" => true]; - } else { + if (!rmdir($real)) { return ["error" => "Failed to delete folder."]; } + + // Remove metadata file (best-effort). + $metadataFile = self::getMetadataFilePath($relative); + if (file_exists($metadataFile)) { + @unlink($metadataFile); + } + + return ["success" => true]; } /** - * Renames a folder and updates related metadata files. - * - * @param string $oldFolder The current folder name (relative to UPLOAD_DIR). - * @param string $newFolder The new folder name. - * @return array Returns an associative array with "success" on success or "error" on failure. + * Renames a folder and updates related metadata files (by renaming their filenames). */ public static function renameFolder(string $oldFolder, string $newFolder): array { - // Sanitize and trim folder names. $oldFolder = trim($oldFolder, "/\\ "); $newFolder = trim($newFolder, "/\\ "); - // Validate folder names. - if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) { - return ["error" => "Invalid folder name(s)."]; + // Validate names (per-segment) + foreach ([$oldFolder, $newFolder] as $f) { + $parts = array_filter(explode('/', $f), fn($p)=>$p!==''); + if (empty($parts)) return ["error" => "Invalid folder name(s)."]; + foreach ($parts as $seg) { + if (!preg_match(REGEX_FOLDER_NAME, $seg)) { + return ["error" => "Invalid folder name(s)."]; + } + } } - // Build the full folder paths. - $baseDir = rtrim(UPLOAD_DIR, '/\\'); - $oldPath = $baseDir . DIRECTORY_SEPARATOR . $oldFolder; - $newPath = $baseDir . DIRECTORY_SEPARATOR . $newFolder; + [$oldReal, $oldRel, $err] = self::resolveFolderPath($oldFolder, false); + if ($err) return ["error" => $err]; - // Validate that the old folder exists and new folder does not. - if ((realpath($oldPath) === false) || (realpath(dirname($newPath)) === false) || - strpos(realpath($oldPath), realpath($baseDir)) !== 0 || - strpos(realpath(dirname($newPath)), realpath($baseDir)) !== 0 - ) { + $base = realpath(UPLOAD_DIR); + if ($base === false) return ["error" => "Uploads directory not configured correctly."]; + + $newParts = array_filter(explode('/', $newFolder), fn($p) => $p!==''); + $newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts); + + // Parent of new path must exist + $newParent = dirname($newPath); + if (!is_dir($newParent) || strpos(realpath($newParent), $base) !== 0) { return ["error" => "Invalid folder path."]; } - - if (!file_exists($oldPath) || !is_dir($oldPath)) { - return ["error" => "Folder to rename does not exist."]; - } - if (file_exists($newPath)) { return ["error" => "New folder name already exists."]; } - // Attempt to rename the folder. - if (rename($oldPath, $newPath)) { - // Update metadata: Rename all metadata files that have the old folder prefix. - $oldPrefix = str_replace(['/', '\\', ' '], '-', $oldFolder); - $newPrefix = str_replace(['/', '\\', ' '], '-', $newFolder); - $metadataFiles = glob(META_DIR . $oldPrefix . '*_metadata.json'); - foreach ($metadataFiles as $oldMetaFile) { - $baseName = basename($oldMetaFile); - $newBaseName = preg_replace('/^' . preg_quote($oldPrefix, '/') . '/', $newPrefix, $baseName); - $newMetaFile = META_DIR . $newBaseName; - rename($oldMetaFile, $newMetaFile); - } - return ["success" => true]; - } else { + if (!rename($oldReal, $newPath)) { return ["error" => "Failed to rename folder."]; } + + // Update metadata filenames (prefix-rename) + $oldPrefix = str_replace(['/', '\\', ' '], '-', $oldRel); + $newPrefix = str_replace(['/', '\\', ' '], '-', implode('/', $newParts)); + $globPat = META_DIR . $oldPrefix . '*_metadata.json'; + $metadataFiles = glob($globPat) ?: []; + + foreach ($metadataFiles as $oldMetaFile) { + $baseName = basename($oldMetaFile); + $newBase = preg_replace('/^' . preg_quote($oldPrefix, '/') . '/', $newPrefix, $baseName); + $newMeta = META_DIR . $newBase; + @rename($oldMetaFile, $newMeta); + } + + return ["success" => true]; } /** - * Recursively scans a directory for subfolders. - * - * @param string $dir The full path to the directory. - * @param string $relative The relative path from the base directory. - * @return array An array of folder paths (relative to the base). + * Recursively scans a directory for subfolders (relative paths). */ private static function getSubfolders(string $dir, string $relative = ''): array { $folders = []; - $items = scandir($dir); - $safeFolderNamePattern = REGEX_FOLDER_NAME; + $items = @scandir($dir) ?: []; foreach ($items as $item) { - if ($item === '.' || $item === '..') { - continue; - } - if (!preg_match($safeFolderNamePattern, $item)) { - continue; - } + if ($item === '.' || $item === '..') continue; + if (!preg_match(REGEX_FOLDER_NAME, $item)) continue; + $path = $dir . DIRECTORY_SEPARATOR . $item; if (is_dir($path)) { $folderPath = ($relative ? $relative . '/' : '') . $item; - $folders[] = $folderPath; - $subFolders = self::getSubfolders($path, $folderPath); - $folders = array_merge($folders, $subFolders); + $folders[] = $folderPath; + $folders = array_merge($folders, self::getSubfolders($path, $folderPath)); } } return $folders; @@ -202,35 +217,31 @@ class FolderModel /** * Retrieves the list of folders (including "root") along with file count metadata. - * - * @return array An array of folder information arrays. */ public static function getFolderList(): array { - $baseDir = rtrim(UPLOAD_DIR, '/\\'); + $baseDir = realpath(UPLOAD_DIR); + if ($baseDir === false) { + return []; // or ["error" => "..."] + } + $folderInfoList = []; - // Process the "root" folder. - $rootMetaFile = self::getMetadataFilePath('root'); - $rootFileCount = 0; + // root + $rootMetaFile = self::getMetadataFilePath('root'); + $rootFileCount = 0; if (file_exists($rootMetaFile)) { $rootMetadata = json_decode(file_get_contents($rootMetaFile), true); $rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0; } $folderInfoList[] = [ - "folder" => "root", - "fileCount" => $rootFileCount, + "folder" => "root", + "fileCount" => $rootFileCount, "metadataFile" => basename($rootMetaFile) ]; - // Recursively scan for subfolders. - if (is_dir($baseDir)) { - $subfolders = self::getSubfolders($baseDir); - } else { - $subfolders = []; - } - - // For each subfolder, load metadata to get file counts. + // subfolders + $subfolders = is_dir($baseDir) ? self::getSubfolders($baseDir) : []; foreach ($subfolders as $folder) { $metaFile = self::getMetadataFilePath($folder); $fileCount = 0; @@ -239,8 +250,8 @@ class FolderModel $fileCount = is_array($metadata) ? count($metadata) : 0; } $folderInfoList[] = [ - "folder" => $folder, - "fileCount" => $fileCount, + "folder" => $folder, + "fileCount" => $fileCount, "metadataFile" => basename($metaFile) ]; } @@ -250,136 +261,101 @@ class FolderModel /** * Retrieves the share folder record for a given token. - * - * @param string $token The share folder token. - * @return array|null The share folder record, or null if not found. */ public static function getShareFolderRecord(string $token): ?array { $shareFile = META_DIR . "share_folder_links.json"; - if (!file_exists($shareFile)) { - return null; - } + if (!file_exists($shareFile)) return null; $shareLinks = json_decode(file_get_contents($shareFile), true); - if (!is_array($shareLinks) || !isset($shareLinks[$token])) { - return null; - } - return $shareLinks[$token]; + return (is_array($shareLinks) && isset($shareLinks[$token])) ? $shareLinks[$token] : null; } /** * Retrieves shared folder data based on a share token. - * - * @param string $token The share folder token. - * @param string|null $providedPass The provided password (if any). - * @param int $page The page number for pagination. - * @param int $itemsPerPage The number of files to display per page. - * @return array Associative array with keys: - * - 'record': the share record, - * - 'folder': the shared folder (relative), - * - 'realFolderPath': absolute folder path, - * - 'files': array of filenames for the current page, - * - 'currentPage': current page number, - * - 'totalPages': total pages, - * or an 'error' key on failure. */ public static function getSharedFolderData(string $token, ?string $providedPass, int $page = 1, int $itemsPerPage = 10): array { - // Load the share folder record. $shareFile = META_DIR . "share_folder_links.json"; - if (!file_exists($shareFile)) { - return ["error" => "Share link not found."]; - } + if (!file_exists($shareFile)) return ["error" => "Share link not found."]; + $shareLinks = json_decode(file_get_contents($shareFile), true); if (!is_array($shareLinks) || !isset($shareLinks[$token])) { return ["error" => "Share link not found."]; } $record = $shareLinks[$token]; - // Check expiration. - if (time() > $record['expires']) { + + if (time() > ($record['expires'] ?? 0)) { return ["error" => "This share link has expired."]; } - // If password protection is enabled and no password is provided, signal that. + if (!empty($record['password']) && empty($providedPass)) { return ["needs_password" => true]; } if (!empty($record['password']) && !password_verify($providedPass, $record['password'])) { return ["error" => "Invalid password."]; } - // Determine the shared folder. - $folder = trim($record['folder'], "/\\ "); - $baseDir = realpath(UPLOAD_DIR); - if ($baseDir === false) { - return ["error" => "Uploads directory not configured correctly."]; - } - if (!empty($folder) && strtolower($folder) !== 'root') { - $folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder; - } else { - $folder = "root"; - $folderPath = $baseDir; - } - $realFolderPath = realpath($folderPath); - $uploadDirReal = realpath(UPLOAD_DIR); - if ($realFolderPath === false || strpos($realFolderPath, $uploadDirReal) !== 0 || !is_dir($realFolderPath)) { + + // Resolve shared folder + $folder = trim((string)$record['folder'], "/\\ "); + [$realFolderPath, $relative, $err] = self::resolveFolderPath($folder === '' ? 'root' : $folder, false); + if ($err || !is_dir($realFolderPath)) { return ["error" => "Shared folder not found."]; } - // Scan for files (only files). - $allFiles = array_values(array_filter(scandir($realFolderPath), function ($item) use ($realFolderPath) { - return is_file($realFolderPath . DIRECTORY_SEPARATOR . $item); - })); - sort($allFiles); - $totalFiles = count($allFiles); - $totalPages = max(1, ceil($totalFiles / $itemsPerPage)); - $currentPage = min($page, $totalPages); - $startIndex = ($currentPage - 1) * $itemsPerPage; + + // List files (safe names only; skip hidden) + $all = @scandir($realFolderPath) ?: []; + $allFiles = []; + foreach ($all as $it) { + if ($it === '.' || $it === '..') continue; + if ($it[0] === '.') continue; + if (!preg_match(REGEX_FILE_NAME, $it)) continue; + if (is_file($realFolderPath . DIRECTORY_SEPARATOR . $it)) { + $allFiles[] = $it; + } + } + sort($allFiles, SORT_NATURAL | SORT_FLAG_CASE); + + $totalFiles = count($allFiles); + $totalPages = max(1, (int)ceil($totalFiles / max(1, $itemsPerPage))); + $currentPage = min(max(1, $page), $totalPages); + $startIndex = ($currentPage - 1) * $itemsPerPage; $filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage); return [ - "record" => $record, - "folder" => $folder, - "realFolderPath" => $realFolderPath, - "files" => $filesOnPage, - "currentPage" => $currentPage, - "totalPages" => $totalPages + "record" => $record, + "folder" => $relative, + "realFolderPath"=> $realFolderPath, + "files" => $filesOnPage, + "currentPage" => $currentPage, + "totalPages" => $totalPages ]; } /** * Creates a share link for a folder. - * - * @param string $folder The folder to share (relative to UPLOAD_DIR). - * @param int $expirationSeconds How many seconds until expiry. - * @param string $password Optional password. - * @param int $allowUpload 0 or 1 whether uploads are allowed. - * @return array ["token","expires","link"] on success, or ["error"]. */ public static function createShareFolderLink(string $folder, int $expirationSeconds = 3600, string $password = "", int $allowUpload = 0): array { - // Validate folder - if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { - return ["error" => "Invalid folder name."]; - } + // Validate folder (and ensure it exists) + [$real, $relative, $err] = self::resolveFolderPath($folder, false); + if ($err) return ["error" => $err]; // Token try { $token = bin2hex(random_bytes(16)); - } catch (Exception $e) { + } catch (\Throwable $e) { return ["error" => "Could not generate token."]; } - // Expiry - $expires = time() + $expirationSeconds; + $expires = time() + max(1, $expirationSeconds); + $hashedPassword= $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : ""; - // Password hash - $hashedPassword = $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : ""; - - // Load existing $shareFile = META_DIR . "share_folder_links.json"; $links = file_exists($shareFile) - ? json_decode(file_get_contents($shareFile), true) ?? [] + ? (json_decode(file_get_contents($shareFile), true) ?? []) : []; - // Cleanup + // cleanup expired $now = time(); foreach ($links as $k => $v) { if (!empty($v['expires']) && $v['expires'] < $now) { @@ -387,107 +363,78 @@ class FolderModel } } - // Add new $links[$token] = [ - "folder" => $folder, + "folder" => $relative, "expires" => $expires, "password" => $hashedPassword, - "allowUpload" => $allowUpload + "allowUpload" => $allowUpload ? 1 : 0 ]; - // Save - if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)) === false) { + if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX) === false) { return ["error" => "Could not save share link."]; } // Build URL - $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http"; - $host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname()); - $baseUrl = $protocol . '://' . rtrim($host, '/'); - $link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token); + $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); + $scheme = $https ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname()); + $baseUrl = $scheme . '://' . rtrim($host, '/'); + $link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token); return ["token" => $token, "expires" => $expires, "link" => $link]; } /** * Retrieves information for a shared file from a shared folder link. - * - * @param string $token The share folder token. - * @param string $file The requested file name. - * @return array An associative array with keys: - * - "error": error message, if any, - * - "realFilePath": the absolute path to the file, - * - "mimeType": the detected MIME type. */ public static function getSharedFileInfo(string $token, string $file): array { - // Load the share folder record. $shareFile = META_DIR . "share_folder_links.json"; - if (!file_exists($shareFile)) { - return ["error" => "Share link not found."]; - } + if (!file_exists($shareFile)) return ["error" => "Share link not found."]; + $shareLinks = json_decode(file_get_contents($shareFile), true); if (!is_array($shareLinks) || !isset($shareLinks[$token])) { return ["error" => "Share link not found."]; } $record = $shareLinks[$token]; - // Check if the link has expired. - if (time() > $record['expires']) { + if (time() > ($record['expires'] ?? 0)) { return ["error" => "This share link has expired."]; } - // Determine the shared folder. - $folder = trim($record['folder'], "/\\ "); - $baseDir = realpath(UPLOAD_DIR); - if ($baseDir === false) { - return ["error" => "Uploads directory not configured correctly."]; - } - if (!empty($folder) && strtolower($folder) !== 'root') { - $folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder; - } else { - $folderPath = $baseDir; - } - $realFolderPath = realpath($folderPath); - $uploadDirReal = realpath(UPLOAD_DIR); - if ($realFolderPath === false || strpos($realFolderPath, $uploadDirReal) !== 0 || !is_dir($realFolderPath)) { + [$realFolderPath, , $err] = self::resolveFolderPath((string)$record['folder'], false); + if ($err || !is_dir($realFolderPath)) { return ["error" => "Shared folder not found."]; } - // Sanitize the file name to prevent path traversal. - if (strpos($file, "/") !== false || strpos($file, "\\") !== false) { + $file = basename(trim($file)); + if (!preg_match(REGEX_FILE_NAME, $file)) { return ["error" => "Invalid file name."]; } - $file = basename($file); - // Build the full file path. - $filePath = $realFolderPath . DIRECTORY_SEPARATOR . $file; - $realFilePath = realpath($filePath); - if ($realFilePath === false || strpos($realFilePath, $realFolderPath) !== 0 || !is_file($realFilePath)) { + $full = $realFolderPath . DIRECTORY_SEPARATOR . $file; + $real = realpath($full); + if ($real === false || strpos($real, $realFolderPath) !== 0 || !is_file($real)) { return ["error" => "File not found."]; } - $mimeType = mime_content_type($realFilePath); - return [ - "realFilePath" => $realFilePath, - "mimeType" => $mimeType - ]; + $mime = function_exists('mime_content_type') ? mime_content_type($real) : 'application/octet-stream'; + return ["realFilePath" => $real, "mimeType" => $mime]; } /** * Handles uploading a file to a shared folder. - * - * @param string $token The share folder token. - * @param array $fileUpload The $_FILES['fileToUpload'] array. - * @return array An associative array with "success" on success or "error" on failure. */ public static function uploadToSharedFolder(string $token, array $fileUpload): array { - // Define maximum file size and allowed extensions. + // Max size & allowed extensions (mirror FileModel’s common types) $maxSize = 50 * 1024 * 1024; // 50 MB - $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'txt', 'xls', 'xlsx', 'ppt', 'pptx', 'mp4', 'webm', 'mp3', 'mkv']; + $allowedExtensions = [ + 'jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx', + 'mp4','webm','mp3','mkv','csv','json','xml','md' + ]; - // Load the share folder record. $shareFile = META_DIR . "share_folder_links.json"; if (!file_exists($shareFile)) { return ["error" => "Share record not found."]; @@ -498,75 +445,50 @@ class FolderModel } $record = $shareLinks[$token]; - // Check expiration. - if (time() > $record['expires']) { + if (time() > ($record['expires'] ?? 0)) { return ["error" => "This share link has expired."]; } - - // Check whether uploads are allowed. - if (empty($record['allowUpload']) || $record['allowUpload'] != 1) { + if (empty($record['allowUpload']) || (int)$record['allowUpload'] !== 1) { return ["error" => "File uploads are not allowed for this share."]; } - // Validate file upload presence. - if ($fileUpload['error'] !== UPLOAD_ERR_OK) { - return ["error" => "File upload error. Code: " . $fileUpload['error']]; + if (($fileUpload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + return ["error" => "File upload error. Code: " . (int)$fileUpload['error']]; } - - if ($fileUpload['size'] > $maxSize) { + if (($fileUpload['size'] ?? 0) > $maxSize) { return ["error" => "File size exceeds allowed limit."]; } - $uploadedName = basename($fileUpload['name']); + $uploadedName = basename((string)($fileUpload['name'] ?? '')); $ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION)); - if (!in_array($ext, $allowedExtensions)) { + if (!in_array($ext, $allowedExtensions, true)) { return ["error" => "File type not allowed."]; } - // Determine the target folder from the share record. - $folderName = trim($record['folder'], "/\\"); - $targetFolder = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR; - if (!empty($folderName) && strtolower($folderName) !== 'root') { - $targetFolder .= $folderName; - } + // Resolve target folder + [$targetDir, $relative, $err] = self::resolveFolderPath((string)$record['folder'], true); + if ($err) return ["error" => $err]; - // Verify target folder exists. - $realTargetFolder = realpath($targetFolder); - $uploadDirReal = realpath(UPLOAD_DIR); - if ($realTargetFolder === false || strpos($realTargetFolder, $uploadDirReal) !== 0 || !is_dir($realTargetFolder)) { - return ["error" => "Shared folder not found."]; - } + // New safe filename + $safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName); + $newFilename= uniqid('', true) . "_" . $safeBase; + $targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename; - // Generate a new filename (using uniqid and sanitizing the original name). - $newFilename = uniqid() . "_" . preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName); - $targetPath = $realTargetFolder . DIRECTORY_SEPARATOR . $newFilename; - - // Move the uploaded file. if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) { return ["error" => "Failed to move the uploaded file."]; } - // --- Metadata Update --- - // Determine metadata file. - $metadataKey = (empty($folderName) || strtolower($folderName) === "root") ? "root" : $folderName; - $metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json'; - $metadataFile = META_DIR . $metadataFileName; - $metadataCollection = []; - if (file_exists($metadataFile)) { - $data = file_get_contents($metadataFile); - $metadataCollection = json_decode($data, true); - if (!is_array($metadataCollection)) { - $metadataCollection = []; - } - } - $uploadedDate = date(DATE_TIME_FORMAT); - $uploader = "Outside Share"; // As per your original implementation. - // Update metadata with the new file's info. - $metadataCollection[$newFilename] = [ - "uploaded" => $uploadedDate, - "uploader" => $uploader + // Update metadata (uploaded + modified + uploader) + $metadataFile = self::getMetadataFilePath($relative); + $meta = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : []; + + $now = date(DATE_TIME_FORMAT); + $meta[$newFilename] = [ + "uploaded" => $now, + "modified" => $now, + "uploader" => "Outside Share" ]; - file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT)); + file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX); return ["success" => "File uploaded successfully.", "newFilename" => $newFilename]; } @@ -574,9 +496,7 @@ class FolderModel public static function getAllShareFolderLinks(): array { $shareFile = META_DIR . "share_folder_links.json"; - if (!file_exists($shareFile)) { - return []; - } + if (!file_exists($shareFile)) return []; $links = json_decode(file_get_contents($shareFile), true); return is_array($links) ? $links : []; } @@ -584,15 +504,13 @@ class FolderModel public static function deleteShareFolderLink(string $token): bool { $shareFile = META_DIR . "share_folder_links.json"; - if (!file_exists($shareFile)) { - return false; - } + if (!file_exists($shareFile)) return false; + $links = json_decode(file_get_contents($shareFile), true); - if (!is_array($links) || !isset($links[$token])) { - return false; - } + if (!is_array($links) || !isset($links[$token])) return false; + unset($links[$token]); - file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)); + file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX); return true; } -} +} \ No newline at end of file diff --git a/src/models/UserModel.php b/src/models/UserModel.php index c2842dc..39ff17a 100644 --- a/src/models/UserModel.php +++ b/src/models/UserModel.php @@ -6,9 +6,7 @@ require_once PROJECT_ROOT . '/config/config.php'; class userModel { /** - * Retrieves all users from the users file. - * - * @return array Returns an array of users. + * Retrieve all users (username + role). */ public static function getAllUsers() { @@ -30,67 +28,75 @@ class userModel } /** - * Adds a new user. + * Add a user. * - * @param string $username The new username. - * @param string $password The plain-text password. - * @param string $isAdmin "1" if admin; "0" otherwise. - * @param bool $setupMode If true, overwrite the users file. - * @return array Response containing either an error or a success message. + * @param string $username + * @param string $password + * @param string $isAdmin "1" or "0" + * @param bool $setupMode overwrite file if true */ public static function addUser($username, $password, $isAdmin, $setupMode) { $usersFile = USERS_DIR . USERS_FILE; - // Ensure users.txt exists. + // Defense in depth + if (!preg_match(REGEX_USER, $username)) { + return ["error" => "Invalid username"]; + } + if (!is_string($password) || $password === '') { + return ["error" => "Password required"]; + } + $isAdmin = $isAdmin === '1' ? '1' : '0'; + if (!file_exists($usersFile)) { - file_put_contents($usersFile, ''); + @file_put_contents($usersFile, '', LOCK_EX); } - // Check if username already exists. - $existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + // Check duplicates + $existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; foreach ($existingUsers as $line) { $parts = explode(':', trim($line)); - if ($username === $parts[0]) { + if (isset($parts[0]) && $username === $parts[0]) { return ["error" => "User already exists"]; } } - // Hash the password. $hashedPassword = password_hash($password, PASSWORD_BCRYPT); + $newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL; - // Prepare the new line. - $newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL; - - // If setup mode, overwrite the file; otherwise, append. if ($setupMode) { - file_put_contents($usersFile, $newUserLine); + if (file_put_contents($usersFile, $newUserLine, LOCK_EX) === false) { + return ["error" => "Failed to write users file"]; + } } else { - file_put_contents($usersFile, $newUserLine, FILE_APPEND); + if (file_put_contents($usersFile, $newUserLine, FILE_APPEND | LOCK_EX) === false) { + return ["error" => "Failed to write users file"]; + } } return ["success" => "User added successfully"]; } /** - * Removes the specified user from the users file and updates the userPermissions file. - * - * @param string $usernameToRemove The username to remove. - * @return array An array with either an error message or a success message. + * Remove a user and update encrypted userPermissions.json. */ public static function removeUser($usernameToRemove) { - $usersFile = USERS_DIR . USERS_FILE; + global $encryptionKey; + if (!preg_match(REGEX_USER, $usernameToRemove)) { + return ["error" => "Invalid username"]; + } + + $usersFile = USERS_DIR . USERS_FILE; if (!file_exists($usersFile)) { return ["error" => "Users file not found"]; } - $existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; $newUsers = []; $userFound = false; - // Loop through users; skip (remove) the specified user. foreach ($existingUsers as $line) { $parts = explode(':', trim($line)); if (count($parts) < 3) { @@ -98,7 +104,7 @@ class userModel } if ($parts[0] === $usernameToRemove) { $userFound = true; - continue; // Do not add this user to the new array. + continue; // skip } $newUsers[] = $line; } @@ -107,17 +113,25 @@ class userModel return ["error" => "User not found"]; } - // Write the updated user list back to the file. - file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL); + $newContent = $newUsers ? (implode(PHP_EOL, $newUsers) . PHP_EOL) : ''; + if (file_put_contents($usersFile, $newContent, LOCK_EX) === false) { + return ["error" => "Failed to update users file"]; + } - // Update the userPermissions.json file. + // Update *encrypted* userPermissions.json consistently $permissionsFile = USERS_DIR . "userPermissions.json"; if (file_exists($permissionsFile)) { - $permissionsJson = file_get_contents($permissionsFile); - $permissionsArray = json_decode($permissionsJson, true); - if (is_array($permissionsArray) && isset($permissionsArray[$usernameToRemove])) { - unset($permissionsArray[$usernameToRemove]); - file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT)); + $raw = file_get_contents($permissionsFile); + $decrypted = decryptData($raw, $encryptionKey); + $permissionsArray = $decrypted !== false + ? json_decode($decrypted, true) + : (json_decode($raw, true) ?: []); // tolerate legacy plaintext + + if (is_array($permissionsArray)) { + unset($permissionsArray[strtolower($usernameToRemove)]); + $plain = json_encode($permissionsArray, JSON_PRETTY_PRINT); + $enc = encryptData($plain, $encryptionKey); + file_put_contents($permissionsFile, $enc, LOCK_EX); } } @@ -125,11 +139,7 @@ class userModel } /** - * Retrieves permissions from the userPermissions.json file. - * If the current user is an admin, returns all permissions. - * Otherwise, returns only the permissions for the current user. - * - * @return array|object Returns an associative array of permissions or an empty object if none are found. + * Get permissions for current user (or all, if admin). */ public static function getUserPermissions() { @@ -137,28 +147,24 @@ class userModel $permissionsFile = USERS_DIR . "userPermissions.json"; $permissionsArray = []; - // Load permissions if the file exists. if (file_exists($permissionsFile)) { $content = file_get_contents($permissionsFile); - // Attempt to decrypt the content. - $decryptedContent = decryptData($content, $encryptionKey); - if ($decryptedContent === false) { - // If decryption fails, assume the content is plain JSON. + $decrypted = decryptData($content, $encryptionKey); + if ($decrypted === false) { + // tolerate legacy plaintext $permissionsArray = json_decode($content, true); } else { - $permissionsArray = json_decode($decryptedContent, true); + $permissionsArray = json_decode($decrypted, true); } if (!is_array($permissionsArray)) { $permissionsArray = []; } } - // If the user is an admin, return all permissions. - if (isset($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) { + if (!empty($_SESSION['isAdmin'])) { return $permissionsArray; } - // Otherwise, return only the permissions for the currently logged-in user. $username = $_SESSION['username'] ?? ''; foreach ($permissionsArray as $storedUsername => $data) { if (strcasecmp($storedUsername, $username) === 0) { @@ -166,129 +172,103 @@ class userModel } } - // If no permissions are found, return an empty object. return new stdClass(); } /** - * Updates user permissions in the userPermissions.json file. - * - * @param array $permissions An array of permission updates. - * @return array An associative array with a success or error message. + * Update permissions (encrypted on disk). Skips admins. */ public static function updateUserPermissions($permissions) - { - global $encryptionKey; - $permissionsFile = USERS_DIR . "userPermissions.json"; - $existingPermissions = []; +{ + global $encryptionKey; + $permissionsFile = USERS_DIR . "userPermissions.json"; + $existingPermissions = []; - // Load existing permissions if available and decrypt. - if (file_exists($permissionsFile)) { - $encryptedContent = file_get_contents($permissionsFile); - $json = decryptData($encryptedContent, $encryptionKey); - $existingPermissions = json_decode($json, true); - if (!is_array($existingPermissions)) { - $existingPermissions = []; - } - } - - // Load user roles from the users file. - $usersFile = USERS_DIR . USERS_FILE; - $userRoles = []; - if (file_exists($usersFile)) { - $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - foreach ($lines as $line) { - $parts = explode(':', trim($line)); - if (count($parts) >= 3 && preg_match(REGEX_USER, $parts[0])) { - // Use lowercase keys for consistency. - $userRoles[strtolower($parts[0])] = trim($parts[2]); - } - } - } - - // Process each permission update. - foreach ($permissions as $perm) { - if (!isset($perm['username'])) { - continue; - } - $username = $perm['username']; - // Look up the user's role. - $role = isset($userRoles[strtolower($username)]) ? $userRoles[strtolower($username)] : null; - - // Skip updating permissions for admin users. - if ($role === "1") { - continue; - } - - // Update permissions: default any missing value to false. - $existingPermissions[strtolower($username)] = [ - 'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false, - 'readOnly' => isset($perm['readOnly']) ? (bool)$perm['readOnly'] : false, - 'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false - ]; - } - - // Convert the updated permissions array to JSON. - $plainText = json_encode($existingPermissions, JSON_PRETTY_PRINT); - // Encrypt the JSON. - $encryptedData = encryptData($plainText, $encryptionKey); - // Save encrypted permissions back to the file. - $result = file_put_contents($permissionsFile, $encryptedData); - if ($result === false) { - return ["error" => "Failed to save user permissions."]; - } - - return ["success" => "User permissions updated successfully."]; + // Load existing (decrypt if needed) + if (file_exists($permissionsFile)) { + $encryptedContent = file_get_contents($permissionsFile); + $json = decryptData($encryptedContent, $encryptionKey); + if ($json === false) $json = $encryptedContent; // plain JSON fallback + $existingPermissions = json_decode($json, true) ?: []; } + // Load roles to skip admins + $usersFile = USERS_DIR . USERS_FILE; + $userRoles = []; + if (file_exists($usersFile)) { + foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + $parts = explode(':', trim($line)); + if (count($parts) >= 3 && preg_match(REGEX_USER, $parts[0])) { + $userRoles[strtolower($parts[0])] = trim($parts[2]); + } + } + } + + $knownKeys = [ + 'folderOnly','readOnly','disableUpload', + 'bypassOwnership','canShare','canZip','viewOwnOnly' + ]; + + foreach ($permissions as $perm) { + if (empty($perm['username'])) continue; + $uname = strtolower($perm['username']); + $role = $userRoles[$uname] ?? null; + if ($role === "1") continue; // skip admins + + $current = $existingPermissions[$uname] ?? []; + foreach ($knownKeys as $k) { + if (array_key_exists($k, $perm)) { + $current[$k] = (bool)$perm[$k]; + } elseif (!isset($current[$k])) { + // default missing keys to false (preserve existing if set) + $current[$k] = false; + } + } + $existingPermissions[$uname] = $current; + } + + $plain = json_encode($existingPermissions, JSON_PRETTY_PRINT); + $encrypted = encryptData($plain, $encryptionKey); + if (file_put_contents($permissionsFile, $encrypted) === false) { + return ["error" => "Failed to save user permissions."]; + } + return ["success" => "User permissions updated successfully."]; +} + /** - * Changes the password for the given user. - * - * @param string $username The username whose password is to be changed. - * @param string $oldPassword The old (current) password. - * @param string $newPassword The new password. - * @return array An array with either a success or error message. + * Change password (preserve TOTP + extra fields). */ public static function changePassword($username, $oldPassword, $newPassword) { - $usersFile = USERS_DIR . USERS_FILE; + if (!preg_match(REGEX_USER, $username)) { + return ["error" => "Invalid username"]; + } + $usersFile = USERS_DIR . USERS_FILE; if (!file_exists($usersFile)) { return ["error" => "Users file not found"]; } - $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; $userFound = false; $newLines = []; foreach ($lines as $line) { $parts = explode(':', trim($line)); - // Expect at least 3 parts: username, hashed password, and role. if (count($parts) < 3) { $newLines[] = $line; continue; } $storedUser = $parts[0]; $storedHash = $parts[1]; - $storedRole = $parts[2]; - // Preserve TOTP secret if it exists. - $totpSecret = (count($parts) >= 4) ? $parts[3] : ""; if ($storedUser === $username) { $userFound = true; - // Verify the old password. if (!password_verify($oldPassword, $storedHash)) { return ["error" => "Old password is incorrect."]; } - // Hash the new password. - $newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT); - - // Rebuild the line, preserving TOTP secret if it exists. - if ($totpSecret !== "") { - $newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret; - } else { - $newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole; - } + $parts[1] = password_hash($newPassword, PASSWORD_BCRYPT); + $newLines[] = implode(':', $parts); } else { $newLines[] = $line; } @@ -298,148 +278,128 @@ class userModel return ["error" => "User not found."]; } - // Save the updated users file. - if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) { - return ["success" => "Password updated successfully."]; - } else { + $payload = implode(PHP_EOL, $newLines) . PHP_EOL; + if (file_put_contents($usersFile, $payload, LOCK_EX) === false) { return ["error" => "Could not update password."]; } + + return ["success" => "Password updated successfully."]; } /** - * Updates the user panel settings by disabling the TOTP secret if TOTP is not enabled. - * - * @param string $username The username whose panel settings are being updated. - * @param bool $totp_enabled Whether TOTP is enabled. - * @return array An array indicating success or failure. + * Update panel: if TOTP disabled, clear secret. */ public static function updateUserPanel($username, $totp_enabled) { - $usersFile = USERS_DIR . USERS_FILE; + if (!preg_match(REGEX_USER, $username)) { + return ["error" => "Invalid username"]; + } + $usersFile = USERS_DIR . USERS_FILE; if (!file_exists($usersFile)) { return ["error" => "Users file not found"]; } - // If TOTP is disabled, update the file to clear the TOTP secret. if (!$totp_enabled) { - $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; $newLines = []; foreach ($lines as $line) { $parts = explode(':', trim($line)); - // Leave lines with fewer than three parts unchanged. if (count($parts) < 3) { $newLines[] = $line; continue; } - if ($parts[0] === $username) { - // If a fourth field (TOTP secret) exists, clear it; otherwise, append an empty field. - if (count($parts) >= 4) { - $parts[3] = ""; - } else { + while (count($parts) < 4) { $parts[] = ""; } + $parts[3] = ""; $newLines[] = implode(':', $parts); } else { $newLines[] = $line; } } - $result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX); - if ($result === false) { + if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX) === false) { return ["error" => "Failed to disable TOTP secret"]; } return ["success" => "User panel updated: TOTP disabled"]; } - // If TOTP is enabled, do nothing. return ["success" => "User panel updated: TOTP remains enabled"]; } /** - * Disables the TOTP secret for the specified user. - * - * @param string $username The user for whom TOTP should be disabled. - * @return bool True if the secret was cleared; false otherwise. + * Clear TOTP secret. */ public static function disableTOTPSecret($username) { - global $encryptionKey; // In case it's used in this model context. $usersFile = USERS_DIR . USERS_FILE; if (!file_exists($usersFile)) { return false; } - $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; $modified = false; $newLines = []; + foreach ($lines as $line) { $parts = explode(':', trim($line)); - // If the line doesn't have at least three parts, leave it unchanged. if (count($parts) < 3) { $newLines[] = $line; continue; } if ($parts[0] === $username) { - // If a fourth field exists, clear it; otherwise, append an empty field. - if (count($parts) >= 4) { - $parts[3] = ""; - } else { + while (count($parts) < 4) { $parts[] = ""; } + $parts[3] = ""; $modified = true; $newLines[] = implode(":", $parts); } else { $newLines[] = $line; } } + if ($modified) { - file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX); + return file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX) !== false; } return $modified; } /** - * Attempts to recover TOTP for a user using the supplied recovery code. - * - * @param string $userId The user identifier. - * @param string $recoveryCode The recovery code provided by the user. - * @return array An associative array with keys 'status' and 'message'. + * Recover via recovery code. */ public static function recoverTOTP($userId, $recoveryCode) { - // --- Rate‑limit recovery attempts --- + // Rate limit storage $attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json'; - $attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : []; - $key = $_SERVER['REMOTE_ADDR'] . '|' . $userId; + $attempts = is_file($attemptsFile) ? (json_decode(@file_get_contents($attemptsFile), true) ?: []) : []; + $key = ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0') . '|' . $userId; $now = time(); + if (isset($attempts[$key])) { - // Prune attempts older than 15 minutes. - $attempts[$key] = array_filter($attempts[$key], function ($ts) use ($now) { - return $ts > $now - 900; - }); + $attempts[$key] = array_values(array_filter($attempts[$key], fn($ts) => $ts > $now - 900)); } if (count($attempts[$key] ?? []) >= 5) { return ['status' => 'error', 'message' => 'Too many attempts. Try again later.']; } - // --- Load user metadata file --- + // User JSON file $userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json'; if (!file_exists($userFile)) { return ['status' => 'error', 'message' => 'User not found']; } - // --- Open and lock file --- $fp = fopen($userFile, 'c+'); if (!$fp || !flock($fp, LOCK_EX)) { + if ($fp) fclose($fp); return ['status' => 'error', 'message' => 'Server error']; } $fileContents = stream_get_contents($fp); $data = json_decode($fileContents, true) ?: []; - // --- Check recovery code --- if (empty($recoveryCode)) { flock($fp, LOCK_UN); fclose($fp); @@ -448,19 +408,19 @@ class userModel $storedHash = $data['totp_recovery_code'] ?? null; if (!$storedHash || !password_verify($recoveryCode, $storedHash)) { - // Record failed attempt. + // record failed attempt $attempts[$key][] = $now; - file_put_contents($attemptsFile, json_encode($attempts), LOCK_EX); + @file_put_contents($attemptsFile, json_encode($attempts, JSON_PRETTY_PRINT), LOCK_EX); flock($fp, LOCK_UN); fclose($fp); return ['status' => 'error', 'message' => 'Invalid recovery code']; } - // --- Invalidate recovery code --- + // Invalidate code $data['totp_recovery_code'] = null; rewind($fp); ftruncate($fp, 0); - fwrite($fp, json_encode($data)); + fwrite($fp, json_encode($data, JSON_PRETTY_PRINT)); fflush($fp); flock($fp, LOCK_UN); fclose($fp); @@ -469,10 +429,7 @@ class userModel } /** - * Generates a random recovery code. - * - * @param int $length Length of the recovery code. - * @return string + * Generate random recovery code. */ private static function generateRecoveryCode($length = 12) { @@ -486,45 +443,34 @@ class userModel } /** - * Saves a new TOTP recovery code for the specified user. - * - * @param string $userId The username of the user. - * @return array An associative array with the status and recovery code (if successful). + * Save new TOTP recovery code (hash on disk) and return plaintext to caller. */ public static function saveTOTPRecoveryCode($userId) { - // Determine the user file path. $userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json'; - // Ensure the file exists; if not, create it with default data. if (!file_exists($userFile)) { - $defaultData = []; - if (file_put_contents($userFile, json_encode($defaultData)) === false) { + if (file_put_contents($userFile, json_encode([], JSON_PRETTY_PRINT), LOCK_EX) === false) { return ['status' => 'error', 'message' => 'Server error: could not create user file']; } } - // Generate a new recovery code. $recoveryCode = self::generateRecoveryCode(); $recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT); - // Open the file, lock it, and update the totp_recovery_code field. $fp = fopen($userFile, 'c+'); if (!$fp || !flock($fp, LOCK_EX)) { + if ($fp) fclose($fp); return ['status' => 'error', 'message' => 'Server error: could not lock user file']; } - // Read and decode the existing JSON. $contents = stream_get_contents($fp); $data = json_decode($contents, true) ?: []; - - // Update the totp_recovery_code field. $data['totp_recovery_code'] = $recoveryHash; - // Write the new data. rewind($fp); ftruncate($fp, 0); - fwrite($fp, json_encode($data)); // Plain JSON in production. + fwrite($fp, json_encode($data, JSON_PRETTY_PRINT)); fflush($fp); flock($fp, LOCK_UN); fclose($fp); @@ -533,11 +479,7 @@ class userModel } /** - * Sets up TOTP for the specified user by retrieving or generating a TOTP secret, - * then builds and returns a QR code image for the OTPAuth URL. - * - * @param string $username The username for which to set up TOTP. - * @return array An associative array with keys 'imageData' and 'mimeType', or 'error'. + * Setup TOTP & build QR PNG. */ public static function setupTOTP($username) { @@ -548,9 +490,9 @@ class userModel return ['error' => 'Users file not found']; } - // Look for an existing TOTP secret. - $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; $totpSecret = null; + foreach ($lines as $line) { $parts = explode(':', trim($line)); if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) { @@ -559,19 +501,18 @@ class userModel } } - // Use the TwoFactorAuth library to create a new secret if none found. $tfa = new \RobThree\Auth\TwoFactorAuth( - new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), // QR code provider - 'FileRise', // issuer - 6, // number of digits - 30, // period (seconds) - \RobThree\Auth\Algorithm::Sha1 // algorithm + new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), + 'FileRise', + 6, + 30, + \RobThree\Auth\Algorithm::Sha1 ); + if (!$totpSecret) { $totpSecret = $tfa->createSecret(); $encryptedSecret = encryptData($totpSecret, $encryptionKey); - // Update the user’s line with the new encrypted secret. $newLines = []; foreach ($lines as $line) { $parts = explode(':', trim($line)); @@ -589,8 +530,7 @@ class userModel file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX); } - // Determine the OTPAuth URL. - // Try to load a global OTPAuth URL template from admin configuration. + // Prefer admin-configured otpauth template if present $adminConfigFile = USERS_DIR . 'adminConfig.json'; $globalOtpauthUrl = ""; if (file_exists($adminConfigFile)) { @@ -598,7 +538,7 @@ class userModel $decryptedContent = decryptData($encryptedContent, $encryptionKey); if ($decryptedContent !== false) { $config = json_decode($decryptedContent, true); - if (isset($config['globalOtpauthUrl']) && !empty($config['globalOtpauthUrl'])) { + if (!empty($config['globalOtpauthUrl'])) { $globalOtpauthUrl = $config['globalOtpauthUrl']; } } @@ -606,14 +546,17 @@ class userModel if (!empty($globalOtpauthUrl)) { $label = "FileRise:" . $username; - $otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl); + $otpauthUrl = str_replace( + ["{label}", "{secret}"], + [urlencode($label), $totpSecret], + $globalOtpauthUrl + ); } else { - $label = urlencode("FileRise:" . $username); + $label = urlencode("FileRise:" . $username); $issuer = urlencode("FileRise"); $otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}"; } - // Build the QR code image using the Endroid QR Code Builder. $result = \Endroid\QrCode\Builder\Builder::create() ->writer(new \Endroid\QrCode\Writer\PngWriter()) ->data($otpauthUrl) @@ -626,10 +569,7 @@ class userModel } /** - * Retrieves the decrypted TOTP secret for a given user. - * - * @param string $username - * @return string|null Returns the TOTP secret if found, or null if not. + * Get decrypted TOTP secret. */ public static function getTOTPSecret($username) { @@ -638,10 +578,9 @@ class userModel if (!file_exists($usersFile)) { return null; } - $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; foreach ($lines as $line) { $parts = explode(':', trim($line)); - // Expect at least 4 parts: username, hash, role, and TOTP secret. if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) { return decryptData($parts[3], $encryptionKey); } @@ -650,10 +589,7 @@ class userModel } /** - * Helper to get a user's role from users.txt. - * - * @param string $username - * @return string|null + * Get role ('1' admin, '0' user) or null. */ public static function getUserRole($username) { @@ -670,27 +606,30 @@ class userModel return null; } + /** + * Get a single user’s info (admin flag, TOTP status, profile picture). + */ public static function getUser(string $username): array { $usersFile = USERS_DIR . USERS_FILE; - if (! file_exists($usersFile)) { + if (!file_exists($usersFile)) { return []; } - + foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { - // split *all* the fields $parts = explode(':', $line); - if ($parts[0] !== $username) { continue; } - - // determine admin & totp $isAdmin = (isset($parts[2]) && $parts[2] === '1'); $totpEnabled = !empty($parts[3]); - // profile_picture is the 5th field if present $pic = isset($parts[4]) ? $parts[4] : ''; - + + // Normalize to a leading slash (UI expects /uploads/…) + if ($pic !== '' && $pic[0] !== '/') { + $pic = '/' . $pic; + } + return [ 'username' => $parts[0], 'isAdmin' => $isAdmin, @@ -698,49 +637,44 @@ class userModel 'profile_picture' => $pic, ]; } - - return []; // user not found + + return []; } /** - * Persistently set the profile picture URL for a given user, - * storing it in the 5th field so we leave the 4th (TOTP secret) untouched. + * Persist profile picture URL as 5th field (keeps TOTP secret intact). * - * users.txt format: - * username:hash:isAdmin:totp_secret:profile_picture - * - * @param string $username - * @param string $url The public URL (e.g. "/uploads/profile_pics/…") - * @return array ['success'=>true] or ['success'=>false,'error'=>'…'] + * users.txt: username:hash:isAdmin:totp_secret:profile_picture */ public static function setProfilePicture(string $username, string $url): array { $usersFile = USERS_DIR . USERS_FILE; - if (! file_exists($usersFile)) { + if (!file_exists($usersFile)) { return ['success' => false, 'error' => 'Users file not found']; } - $lines = file($usersFile, FILE_IGNORE_NEW_LINES); + // Ensure leading slash (consistent with controller response) + $url = '/' . ltrim($url, '/'); + + $lines = file($usersFile, FILE_IGNORE_NEW_LINES) ?: []; $out = []; $found = false; foreach ($lines as $line) { + if ($line === '') { $out[] = $line; continue; } $parts = explode(':', $line); if ($parts[0] === $username) { $found = true; - // Ensure we have at least 5 fields while (count($parts) < 5) { $parts[] = ''; } - // Write profile_picture into the 5th field (index 4) - $parts[4] = ltrim($url, '/'); // or $url if leading slash is desired - // Re-assemble (this preserves parts[3] completely) + $parts[4] = $url; $line = implode(':', $parts); } $out[] = $line; } - if (! $found) { + if (!$found) { return ['success' => false, 'error' => 'User not found']; } @@ -751,4 +685,4 @@ class userModel return ['success' => true]; } -} +} \ No newline at end of file