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:
-
-
-
This folder is empty.
-
- | Filename |
- Size |
-
+ | Filename | Size |
-
-
- |
-
-
- ⇩
-
- |
- |
-
-
+
+
+ |
+
+ ⇩
+
+ |
+ |
+
+
-
-
-
-
+
Upload File
- ( max size)
+ ( max size)
-
-
+
+
-
- "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