feat(permissions)!: granular ACL (bypassOwnership/canShare/canZip/viewOwnOnly), admin panel v1.4.0 UI, and broad hardening across controllers/models/frontend (closes #53)

This commit is contained in:
Ryan
2025-10-15 23:56:39 -04:00
committed by GitHub
parent f2ab2a96bc
commit 25ce6a76be
14 changed files with 2554 additions and 2206 deletions

View File

@@ -1,5 +1,102 @@
# Changelog # 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 callers 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 dont 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 dont 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) ## Changes 10/8/2025 (no new version)
chore: set up CI, add compose, tighten ignores, refresh README 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** - **Folder strip in file list**
- `loadFileList` now fetches sub-folders in parallel from `/api/folder/getFolderList.php`. - `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). - Injects a new `.folder-strip-container` just below the Files In above (summary + slider).
- Clicking a folder in the strip updates: - Clicking a folder in the strip updates:
- the breadcrumb (via `updateBreadcrumbTitle`) - 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: - Moved previously standalone header buttons into the dropdown menu:
- **User Panel** opens the modal - **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()` - **API Docs** calls `openApiModal()`
- **Logout** calls `triggerLogout()` - **Logout** calls `triggerLogout()`
- Each menu item now has a matching Material icon (e.g. `person`, `admin_panel_settings`, `description`, `logout`). - 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: - 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. - Overwrote `AUTH_BYPASS` with the `loginOptions.authBypass` setting from disk.
- Defined `AUTH_HEADER` (normalized, e.g. `"X_REMOTE_USER"`) based on `loginOptions.authHeaderName`. - 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. 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 - 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. - 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. - 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. - **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;"` - **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). - **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. - **Updated** DOMContentLoaded `checkAuthentication()` flow to call `initializeApp()` when already authenticated.

View File

@@ -2,7 +2,12 @@
## Supported Versions ## 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 ## Reporting a Vulnerability

View File

@@ -36,6 +36,13 @@ define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
date_default_timezone_set(TIMEZONE); 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 // Encryption helpers
function encryptData($data, $encryptionKey) function encryptData($data, $encryptionKey)
{ {

View File

@@ -3,9 +3,15 @@ import { loadAdminConfigFunc } from './auth.js';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js';
const version = "v1.3.15"; const version = "v1.4.0";
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`; const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
// 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 ————— // ————— Inject updated styles —————
(function () { (function () {
if (document.getElementById('adminPanelStyles')) return; if (document.getElementById('adminPanelStyles')) return;
@@ -493,21 +499,21 @@ export function openAdminPanel() {
} }
function handleSave() { function handleSave() {
const dFL = document.getElementById("disableFormLogin").checked; const dFL = document.getElementById("disableFormLogin").checked;
const dBA = document.getElementById("disableBasicAuth").checked; const dBA = document.getElementById("disableBasicAuth").checked;
const dOIDC = document.getElementById("disableOIDCLogin").checked; const dOIDC = document.getElementById("disableOIDCLogin").checked;
const aBypass= document.getElementById("authBypass").checked; const aBypass = document.getElementById("authBypass").checked;
const aHeader= document.getElementById("authHeaderName").value.trim() || "X-Remote-User"; const aHeader = document.getElementById("authHeaderName").value.trim() || "X-Remote-User";
const eWD = document.getElementById("enableWebDAV").checked; const eWD = document.getElementById("enableWebDAV").checked;
const sMax = parseInt(document.getElementById("sharedMaxUploadSize").value, 10) || 0; const sMax = parseInt(document.getElementById("sharedMaxUploadSize").value, 10) || 0;
const nHT = document.getElementById("headerTitle").value.trim(); const nHT = document.getElementById("headerTitle").value.trim();
const nOIDC = { const nOIDC = {
providerUrl: document.getElementById("oidcProviderUrl").value.trim(), providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
clientId: document.getElementById("oidcClientId").value.trim(), clientId: document.getElementById("oidcClientId").value.trim(),
clientSecret:document.getElementById("oidcClientSecret").value.trim(), clientSecret: document.getElementById("oidcClientSecret").value.trim(),
redirectUri: document.getElementById("oidcRedirectUri").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) { if ([dFL, dBA, dOIDC].filter(x => x).length === 3) {
showToast(t("at_least_one_login_method")); showToast(t("at_least_one_login_method"));
@@ -521,25 +527,25 @@ function handleSave() {
disableFormLogin: dFL, disableFormLogin: dFL,
disableBasicAuth: dBA, disableBasicAuth: dBA,
disableOIDCLogin: dOIDC, disableOIDCLogin: dOIDC,
authBypass: aBypass, authBypass: aBypass,
authHeaderName: aHeader authHeaderName: aHeader
}, },
enableWebDAV: eWD, enableWebDAV: eWD,
sharedMaxUploadSize: sMax, sharedMaxUploadSize: sMax,
globalOtpauthUrl: gURL globalOtpauthUrl: gURL
}, { }, {
"X-CSRF-Token": window.csrfToken "X-CSRF-Token": window.csrfToken
}) })
.then(res => { .then(res => {
if (res.success) { if (res.success) {
showToast(t("settings_updated_successfully"), "success"); showToast(t("settings_updated_successfully"), "success");
captureInitialAdminConfig(); captureInitialAdminConfig();
closeAdminPanel(); closeAdminPanel();
loadAdminConfigFunc(); loadAdminConfigFunc();
} else { } else {
showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error"); showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error");
} }
}).catch(() => {/*noop*/}); }).catch(() => {/*noop*/ });
} }
export async function closeAdminPanel() { export async function closeAdminPanel() {
@@ -605,15 +611,16 @@ export function openUserPermissionsModal() {
const rows = userPermissionsModal.querySelectorAll(".user-permission-row"); const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
const permissionsData = []; const permissionsData = [];
rows.forEach(row => { rows.forEach(row => {
const username = row.getAttribute("data-username"); const g = k => row.querySelector(`input[data-permission='${k}']`)?.checked ?? false;
const folderOnlyCheckbox = row.querySelector("input[data-permission='folderOnly']");
const readOnlyCheckbox = row.querySelector("input[data-permission='readOnly']");
const disableUploadCheckbox = row.querySelector("input[data-permission='disableUpload']");
permissionsData.push({ permissionsData.push({
username, username: row.getAttribute("data-username"),
folderOnly: folderOnlyCheckbox.checked, folderOnly: g("folderOnly"),
readOnly: readOnlyCheckbox.checked, readOnly: g("readOnly"),
disableUpload: disableUploadCheckbox.checked disableUpload: g("disableUpload"),
bypassOwnership: g("bypassOwnership"),
canShare: g("canShare"),
canZip: g("canZip"),
viewOwnOnly: g("viewOwnOnly"),
}); });
}); });
// Send the permissionsData to the server. // Send the permissionsData to the server.
@@ -664,38 +671,79 @@ function loadUserPermissionsList() {
folderOnly: false, folderOnly: false,
readOnly: false, readOnly: false,
disableUpload: false, disableUpload: false,
bypassOwnership: false,
canShare: false,
canZip: false,
viewOwnOnly: false,
}; };
// Normalize the username key to match server storage (e.g., lowercase) // Normalize the username key to match server storage (e.g., lowercase)
const usernameKey = user.username.toLowerCase(); const usernameKey = user.username.toLowerCase();
const toBool = v => v === true || v === 1 || v === "1";
const userPerm = (permissionsData && typeof permissionsData === "object" && (usernameKey in permissionsData)) const userPerm = (permissionsData && typeof permissionsData === "object" && (usernameKey in permissionsData))
? permissionsData[usernameKey] ? permissionsData[usernameKey]
: defaultPerm; : defaultPerm;
// Create a row for the user.
const row = document.createElement("div"); // Create a row for the user (collapsed by default)
row.classList.add("user-permission-row"); const row = document.createElement("div");
row.setAttribute("data-username", user.username); row.classList.add("user-permission-row");
row.style.padding = "10px 0"; row.setAttribute("data-username", user.username);
row.innerHTML = ` row.style.padding = "6px 0";
<div style="font-weight: bold; margin-bottom: 5px;">${user.username}</div>
<div style="display: flex; flex-direction: column; gap: 5px;"> // helper for checkbox checked state
<label style="display: flex; align-items: center; gap: 5px;"> const checked = key => (userPerm && userPerm[key]) ? "checked" : "";
<input type="checkbox" data-permission="folderOnly" ${userPerm.folderOnly ? "checked" : ""} />
${t("user_folder_only")} // header + caret
</label> row.innerHTML = `
<label style="display: flex; align-items: center; gap: 5px;"> <div class="user-perm-header"
<input type="checkbox" data-permission="readOnly" ${userPerm.readOnly ? "checked" : ""} /> role="button"
${t("read_only")} tabindex="0"
</label> aria-expanded="false"
<label style="display: flex; align-items: center; gap: 5px;"> style="display:flex;align-items:center;justify-content:space-between;
<input type="checkbox" data-permission="disableUpload" ${userPerm.disableUpload ? "checked" : ""} /> padding:8px 6px;border-radius:6px;cursor:pointer;
${t("disable_upload")} background:var(--perm-header-bg, rgba(0,0,0,0.04));">
</label> <span style="font-weight:600;">${user.username}</span>
</div> <i class="material-icons perm-caret" style="transition:transform .2s; transform:rotate(-90deg);">expand_more</i>
<hr style="margin-top: 10px; border: 0; border-bottom: 1px solid #ccc;"> </div>
`;
<div class="user-perm-details"
style="display:none;margin:8px 4px 2px 10px;
display:none;gap:8px;
grid-template-columns: 1fr 1fr;">
<label><input type="checkbox" data-permission="folderOnly" ${checked("folderOnly")}/> ${t("user_folder_only")}</label>
<label><input type="checkbox" data-permission="readOnly" ${checked("readOnly")}/> ${t("read_only")}</label>
<label><input type="checkbox" data-permission="disableUpload" ${checked("disableUpload")}/> ${t("disable_upload")}</label>
<label><input type="checkbox" data-permission="bypassOwnership" ${checked("bypassOwnership")}/> Bypass ownership</label>
<label><input type="checkbox" data-permission="canShare" ${checked("canShare")}/> Can share</label>
<label><input type="checkbox" data-permission="canZip" ${checked("canZip")}/> Can zip</label>
<label><input type="checkbox" data-permission="viewOwnOnly" ${checked("viewOwnOnly")}/> View own files only</label>
</div>
<hr style="margin:8px 0 4px;border:0;border-bottom:1px solid #ccc;">
`;
// 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); listContainer.appendChild(row);
}); });
}); });

View File

@@ -55,6 +55,18 @@ window.advancedSearchEnabled = false;
* --- Helper Functions --- * --- 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. * 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 { try {
// Kick off both in parallel, but we'll render as soon as FILES are ready // 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 filesPromise = fetch(
const foldersPromise = fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`); `/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 ----- // ----- FILES FIRST -----
const filesRes = await filesPromise; const filesRes = await filesPromise;
@@ -230,7 +248,10 @@ export async function loadFileList(folderParam) {
throw new Error("Unauthorized"); 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 another loadFileList ran after this one, bail before touching the DOM
if (reqId !== __fileListReqSeq) return []; if (reqId !== __fileListReqSeq) return [];
@@ -403,7 +424,7 @@ export async function loadFileList(folderParam) {
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) ----- // ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
try { try {
const foldersRes = await foldersPromise; 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; if (reqId !== __fileListReqSeq) return data.files;
// --- build ONLY the *direct* children of current folder --- // --- build ONLY the *direct* children of current folder ---

View File

@@ -2,8 +2,6 @@ import { sendRequest } from './networkUtils.js';
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js'; import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
import { initUpload } from './upload.js'; import { initUpload } from './upload.js';
import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js'; import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js';
const _originalFetch = window.fetch;
window.fetch = fetchWithCsrf;
import { loadFolderTree } from './folderManager.js'; import { loadFolderTree } from './folderManager.js';
import { setupTrashRestoreDelete } from './trashRestoreDelete.js'; import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js'; import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
@@ -14,14 +12,60 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload }
import { editFile, saveFile } from './fileEditor.js'; import { editFile, saveFile } from './fileEditor.js';
import { t, applyTranslations, setLocale } from './i18n.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() { export function initializeApp() {
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10); const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
document.documentElement.style.setProperty('--file-row-height', saved + 'px'); document.documentElement.style.setProperty('--file-row-height', saved + 'px');
window.currentFolder = "root"; window.currentFolder = "root";
initTagSearch();
loadFileList(window.currentFolder);
const stored = localStorage.getItem('showFoldersInList'); const stored = localStorage.getItem('showFoldersInList');
window.showFoldersInList = stored === null ? true : stored === 'true'; window.showFoldersInList = stored === null ? true : stored === 'true';
initTagSearch();
loadFileList(window.currentFolder);
const fileListArea = document.getElementById('fileListContainer'); const fileListArea = document.getElementById('fileListContainer');
const uploadArea = document.getElementById('uploadDropArea'); const uploadArea = document.getElementById('uploadDropArea');
if (fileListArea && uploadArea) { if (fileListArea && uploadArea) {
@@ -35,7 +79,6 @@ export function initializeApp() {
fileListArea.addEventListener('drop', e => { fileListArea.addEventListener('drop', e => {
e.preventDefault(); e.preventDefault();
fileListArea.classList.remove('drop-hover'); fileListArea.classList.remove('drop-hover');
// re-dispatch the same drop into the real upload card
uploadArea.dispatchEvent(new DragEvent('drop', { uploadArea.dispatchEvent(new DragEvent('drop', {
dataTransfer: e.dataTransfer, dataTransfer: e.dataTransfer,
bubbles: true, 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() { export function loadCsrfToken() {
return fetchWithCsrf('/api/auth/token.php', { method: 'GET' }) return _nativeFetch('/api/auth/token.php', { method: 'GET', credentials: 'include' })
.then(res => { .then(async res => {
if (!res.ok) throw new Error(`Token fetch failed with status ${res.status}`); // header-based rotation
return res.json(); const hdr = res.headers.get('X-CSRF-Token');
}) if (hdr) setCsrfToken(hdr);
.then(({ csrf_token, share_url }) => {
window.csrfToken = csrf_token;
// update CSRF meta // body (if provided)
let meta = document.querySelector('meta[name="csrf-token"]') || let body = {};
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'csrf-token' }); try { body = await res.json(); } catch { /* token endpoint may return empty */ }
meta.content = csrf_token;
// 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; const actualShare = window.location.origin;
let shareMeta = document.querySelector('meta[name="share-url"]') || let shareMeta = document.querySelector('meta[name="share-url"]');
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'share-url' }); if (!shareMeta) {
shareMeta = document.createElement('meta');
shareMeta.name = 'share-url';
document.head.appendChild(shareMeta);
}
shareMeta.content = actualShare; 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() { export function triggerLogout() {
fetch("/api/auth/logout.php", { _nativeFetch("/api/auth/logout.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "X-CSRF-Token": window.csrfToken } headers: { "X-CSRF-Token": getCsrfToken() }
}) })
.then(() => window.location.reload(true)) .then(() => window.location.reload(true))
.catch(() => { }); .catch(() => { });
} }
// Expose functions for inline handlers. // Expose functions for inline handlers.
window.sendRequest = sendRequest; window.sendRequest = sendRequest;
window.toggleVisibility = toggleVisibility; window.toggleVisibility = toggleVisibility;
@@ -119,105 +170,79 @@ window.openDownloadModal = openDownloadModal;
window.currentFolder = "root"; window.currentFolder = "root";
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
loadAdminConfigFunc();
loadAdminConfigFunc(); // Then fetch the latest config and update. // i18n
// Retrieve the saved language from localStorage; default to "en"
const savedLanguage = localStorage.getItem("language") || "en"; const savedLanguage = localStorage.getItem("language") || "en";
// Set the locale based on the saved language
setLocale(savedLanguage); setLocale(savedLanguage);
// Apply the translations to update the UI
applyTranslations(); 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: // 1) Get/refresh CSRF first
checkAuthentication().then(authenticated => { loadCsrfToken()
if (authenticated) { .then(() => {
const overlay = document.getElementById('loadingOverlay'); // 2) Auth boot
if (overlay) overlay.remove(); initAuth();
initializeApp();
}
});
// Other DOM initialization that can happen after CSRF is ready. // 3) If authenticated, start app
const newPasswordInput = document.getElementById("newPassword"); checkAuthentication().then(authenticated => {
if (newPasswordInput) { if (authenticated) {
newPasswordInput.addEventListener("input", function () { const overlay = document.getElementById('loadingOverlay');
console.log("newPassword input event:", this.value); if (overlay) overlay.remove();
initializeApp();
}
}); });
} else {
console.error("newPassword input not found!");
}
// --- Dark Mode Persistence --- // --- Dark Mode Persistence ---
const darkModeToggle = document.getElementById("darkModeToggle"); const darkModeToggle = document.getElementById("darkModeToggle");
const darkModeIcon = document.getElementById("darkModeIcon"); const darkModeIcon = document.getElementById("darkModeIcon");
if (darkModeToggle && darkModeIcon) { if (darkModeToggle && darkModeIcon) {
// 1) Load stored preference (or null) let stored = localStorage.getItem("darkMode");
let stored = localStorage.getItem("darkMode"); const hasStored = stored !== null;
const hasStored = stored !== null;
// 2) Determine initial mode const isDark = hasStored
const isDark = hasStored ? (stored === "true")
? (stored === "true") : (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.body.classList.toggle("dark-mode", isDark); document.body.classList.toggle("dark-mode", isDark);
darkModeToggle.classList.toggle("active", isDark); darkModeToggle.classList.toggle("active", isDark);
// 3) Helper to update icon & aria-label function updateIcon() {
function updateIcon() { const dark = document.body.classList.contains("dark-mode");
const dark = document.body.classList.contains("dark-mode"); darkModeIcon.textContent = dark ? "light_mode" : "dark_mode";
darkModeIcon.textContent = dark ? "light_mode" : "dark_mode"; darkModeToggle.setAttribute("aria-label", dark ? t("light_mode") : t("dark_mode"));
darkModeToggle.setAttribute( darkModeToggle.setAttribute("title", dark ? t("switch_to_light_mode") : t("switch_to_dark_mode"));
"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");
updateIcon(); updateIcon();
});
// 5) OSlevel change: only if no stored pref at load darkModeToggle.addEventListener("click", () => {
if (!hasStored && window.matchMedia) { const nowDark = document.body.classList.toggle("dark-mode");
window localStorage.setItem("darkMode", nowDark ? "true" : "false");
.matchMedia("(prefers-color-scheme: dark)") updateIcon();
.addEventListener("change", e => { });
if (!hasStored && window.matchMedia) {
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => {
document.body.classList.toggle("dark-mode", e.matches); document.body.classList.toggle("dark-mode", e.matches);
updateIcon(); updateIcon();
}); });
}
} }
} // --- End Dark Mode Persistence ---
// --- End Dark Mode Persistence ---
const message = sessionStorage.getItem("welcomeMessage"); const message = sessionStorage.getItem("welcomeMessage");
if (message) { if (message) {
showToast(message); showToast(message);
sessionStorage.removeItem("welcomeMessage"); sessionStorage.removeItem("welcomeMessage");
} }
}).catch(error => { })
console.error("Initialization halted due to CSRF token load failure.", error); .catch(error => {
}); console.error("Initialization halted due to CSRF token load failure.", error);
});
// --- Auto-scroll During Drag --- // --- Auto-scroll During Drag ---
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling const SCROLL_THRESHOLD = 50;
const SCROLL_SPEED = 20; // pixels to scroll per event const SCROLL_SPEED = 20;
document.addEventListener("dragover", function (e) { document.addEventListener("dragover", function (e) {
if (e.clientY < SCROLL_THRESHOLD) { if (e.clientY < SCROLL_THRESHOLD) {
window.scrollBy(0, -SCROLL_SPEED); window.scrollBy(0, -SCROLL_SPEED);

View File

@@ -53,6 +53,17 @@ class AdminController
public function getConfig(): void public function getConfig(): void
{ {
header('Content-Type: application/json'); 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(); $config = AdminModel::getConfig();
if (isset($config['error'])) { if (isset($config['error'])) {
http_response_code(500); http_response_code(500);
@@ -62,14 +73,14 @@ class AdminController
// Build a safe subset for the front-end // Build a safe subset for the front-end
$safe = [ $safe = [
'header_title' => $config['header_title'], 'header_title' => $config['header_title'] ?? '',
'loginOptions' => $config['loginOptions'], 'loginOptions' => $config['loginOptions'] ?? [],
'globalOtpauthUrl' => $config['globalOtpauthUrl'], 'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
'enableWebDAV' => $config['enableWebDAV'], 'enableWebDAV' => $config['enableWebDAV'] ?? false,
'sharedMaxUploadSize' => $config['sharedMaxUploadSize'], 'sharedMaxUploadSize' => $config['sharedMaxUploadSize'] ?? 0,
'oidc' => [ 'oidc' => [
'providerUrl' => $config['oidc']['providerUrl'], 'providerUrl' => $config['oidc']['providerUrl'] ?? '',
'redirectUri' => $config['oidc']['redirectUri'], 'redirectUri' => $config['oidc']['redirectUri'] ?? '',
// clientSecret and clientId never exposed here // clientSecret and clientId never exposed here
], ],
]; ];
@@ -137,106 +148,186 @@ class AdminController
* @return void Outputs a JSON response indicating success or failure. * @return void Outputs a JSON response indicating success or failure.
*/ */
public function updateConfig(): void public function updateConfig(): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
// —– auth & CSRF checks —– // —– auth & CSRF checks —–
if ( if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true || !isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin'] !isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
) { ) {
http_response_code(403); http_response_code(403);
echo json_encode(['error' => 'Unauthorized access.']); echo json_encode(['error' => 'Unauthorized access.']);
exit; 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
);
} }
} $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
if (isset($data['loginOptions']['authHeaderName'])) { $receivedToken = trim($headersArr['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
$hdr = trim($data['loginOptions']['authHeaderName']); if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
if ($hdr !== '') { http_response_code(403);
$merged['loginOptions']['authHeaderName'] = $hdr; echo json_encode(['error' => 'Invalid CSRF token.']);
exit;
} }
}
// globalOtpauthUrl // —– fetch payload —–
if (array_key_exists('globalOtpauthUrl', $data)) { $data = json_decode(file_get_contents('php://input'), true);
$merged['globalOtpauthUrl'] = trim($data['globalOtpauthUrl']); if (!is_array($data)) {
} http_response_code(400);
echo json_encode(['error' => 'Invalid input.']);
exit;
}
// enableWebDAV // —– load existing on-disk config —–
if (array_key_exists('enableWebDAV', $data)) { $existing = AdminModel::getConfig();
$merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN); if (isset($existing['error'])) {
} http_response_code(500);
echo json_encode(['error' => $existing['error']]);
exit;
}
// sharedMaxUploadSize // —– start merge with existing as base —–
if (array_key_exists('sharedMaxUploadSize', $data)) { // Ensure minimal structure if the file was partially missing.
$sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT); $merged = $existing + [
if ($sms !== false) { '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; $merged['sharedMaxUploadSize'] = $sms;
} }
}
// oidc: only overwrite non-empty inputs // oidc: only overwrite non-empty inputs; validate when enabling OIDC
$merged['oidc'] = $existing['oidc'] ?? [ foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) {
'providerUrl'=>'','clientId'=>'','clientSecret'=>'','redirectUri'=>'' if (!empty($data['oidc'][$f])) {
]; $val = trim((string)$data['oidc'][$f]);
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) { if ($f === 'providerUrl' || $f === 'redirectUri') {
if (!empty($data['oidc'][$f])) { $val = filter_var($val, FILTER_SANITIZE_URL);
$val = trim($data['oidc'][$f]); }
if ($f === 'providerUrl' || $f === 'redirectUri') { $merged['oidc'][$f] = $val;
$val = filter_var($val, FILTER_SANITIZE_URL);
} }
$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 —– /** Convert php.ini shorthand like "128M" to bytes */
$result = AdminModel::updateConfig($merged); private static function iniToBytes($val)
if (isset($result['error'])) { {
http_response_code(500); 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;
}
} }
?>

View File

@@ -3,9 +3,163 @@
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/models/FileModel.php'; require_once PROJECT_ROOT . '/src/models/FileModel.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
class FileController 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 "<username>" 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( * @OA\Post(
* path="/api/file/copyFiles.php", * path="/api/file/copyFiles.php",
@@ -73,8 +227,8 @@ class FileController
// Check user permissions (assuming loadUserPermissions() is available). // Check user permissions (assuming loadUserPermissions() is available).
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = $this->loadPerms($username);
if (!empty($userPermissions['readOnly'])) { if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to copy files."]); echo json_encode(["error" => "Read-only users are not allowed to copy files."]);
exit; exit;
} }
@@ -106,6 +260,12 @@ class FileController
exit; 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. // Delegate to the model.
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files); $result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
echo json_encode($result); echo json_encode($result);
@@ -177,7 +337,7 @@ class FileController
// Load user's permissions. // Load user's permissions.
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = $this->loadPerms($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) { if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to delete files."]); echo json_encode(["error" => "Read-only users are not allowed to delete files."]);
exit; exit;
@@ -199,6 +359,10 @@ class FileController
} }
$folder = trim($folder, "/\\ "); $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. // Delegate to the FileModel.
$result = FileModel::deleteFiles($folder, $data['files']); $result = FileModel::deleteFiles($folder, $data['files']);
echo json_encode($result); echo json_encode($result);
@@ -271,8 +435,8 @@ class FileController
// Verify that the user is not read-only. // Verify that the user is not read-only.
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = $this->loadPerms($username);
if (!empty($userPermissions['readOnly'])) { if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to move files."]); echo json_encode(["error" => "Read-only users are not allowed to move files."]);
exit; exit;
} }
@@ -303,6 +467,12 @@ class FileController
exit; 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. // Delegate to the model.
$result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['files']); $result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['files']);
echo json_encode($result); echo json_encode($result);
@@ -351,64 +521,63 @@ class FileController
* @return void Outputs a JSON response. * @return void Outputs a JSON response.
*/ */
public function renameFile() public function renameFile()
{ {
header('Content-Type: application/json'); $this->_jsonStart();
header("Cache-Control: no-cache, no-store, must-revalidate"); try {
header("Pragma: no-cache"); if (!$this->_checkCsrf()) return;
header("Expires: 0"); 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'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = $this->loadPerms($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) { if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to rename files."]); $this->_jsonOut(["error" => "Read-only users are not allowed to rename files."], 403);
exit; return;
} }
// Get JSON input. $data = $this->_readJsonBody();
$data = json_decode(file_get_contents("php://input"), true); if (!$data || !isset($data['folder'], $data['oldName'], $data['newName'])) {
if (!$data || !isset($data['folder']) || !isset($data['oldName']) || !isset($data['newName'])) { $this->_jsonOut(["error" => "Invalid input"], 400);
http_response_code(400); return;
echo json_encode(["error" => "Invalid input"]);
exit;
} }
$folder = trim($data['folder']) ?: 'root'; $folder = trim((string)$data['folder']) ?: 'root';
// Validate folder: allow letters, numbers, underscores, dashes, spaces, and forward slashes. $oldName = basename(trim((string)$data['oldName']));
$newName = basename(trim((string)$data['newName']));
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
echo json_encode(["error" => "Invalid folder name"]); $this->_jsonOut(["error" => "Invalid folder name"], 400);
exit; 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'])); // Non-admin must own the original
$newName = basename(trim($data['newName'])); $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); $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( * @OA\Post(
@@ -452,63 +621,75 @@ class FileController
* @return void Outputs a JSON response. * @return void Outputs a JSON response.
*/ */
public function saveFile() public function saveFile()
{ {
header('Content-Type: application/json'); $this->_jsonStart();
try {
// --- CSRF Protection --- if (!$this->_checkCsrf()) return;
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); if (!$this->_requireAuth()) return;
$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;
}
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
// --- Readonly check --- $userPermissions = $this->loadPerms($username);
$userPermissions = loadUserPermissions($username); if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
if ($username && !empty($userPermissions['readOnly'])) { $this->_jsonOut(["error" => "Read-only users are not allowed to save files."], 403);
echo json_encode(["error" => "Read-only users are not allowed to save files."]); return;
exit;
} }
// --- Input parsing --- $data = $this->_readJsonBody();
$data = json_decode(file_get_contents("php://input"), true);
if (empty($data) || !isset($data["fileName"], $data["content"])) { if (empty($data) || !isset($data["fileName"], $data["content"])) {
http_response_code(400); $this->_jsonOut(["error" => "Invalid request data"], 400);
echo json_encode(["error" => "Invalid request data", "received" => $data]); return;
exit;
} }
$fileName = basename($data["fileName"]); $fileName = basename(trim((string)$data["fileName"]));
$folder = isset($data["folder"]) ? trim($data["folder"]) : "root"; $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)) { if (strtolower($folder) !== "root" && !preg_match(REGEX_FOLDER_NAME, $folder)) {
echo json_encode(["error" => "Invalid folder name"]); $this->_jsonOut(["error" => "Invalid folder name"], 400);
exit; return;
} }
$folder = trim($folder, "/\\ ");
// --- Delegate to model, passing the uploader --- // Folder-only users may only write within their scope
// Make sure FileModel::saveFile signature is: $dv = $this->enforceFolderScope($folder, $username, $userPermissions);
// saveFile(string $folder, string $fileName, $content, ?string $uploader = null) if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
$result = FileModel::saveFile(
$folder,
$fileName,
$data["content"],
$username // ← pass the real uploader here
);
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( * @OA\Get(
@@ -582,6 +763,23 @@ class FileController
exit; 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. // Retrieve download info from the model.
$downloadInfo = FileModel::getDownloadInfo($folder, $file); $downloadInfo = FileModel::getDownloadInfo($folder, $file);
if (isset($downloadInfo['error'])) { if (isset($downloadInfo['error'])) {
@@ -676,6 +874,13 @@ class FileController
exit; 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. // Read and decode JSON input.
$data = json_decode(file_get_contents("php://input"), true); $data = json_decode(file_get_contents("php://input"), true);
if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) { 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. // Create ZIP archive using FileModel.
$result = FileModel::createZipArchive($folder, $files); $result = FileModel::createZipArchive($folder, $files);
if (isset($result['error'])) { 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. // Delegate to the model.
$result = FileModel::extractZipArchive($folder, $files); $result = FileModel::extractZipArchive($folder, $files);
echo json_encode($result); echo json_encode($result);
@@ -1078,13 +1305,19 @@ class FileController
// Check user permissions. // Check user permissions.
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = $this->loadPerms($username);
if ($username && !empty($userPermissions['readOnly'])) { if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
http_response_code(403); http_response_code(403);
echo json_encode(["error" => "Read-only users are not allowed to create share links."]); echo json_encode(["error" => "Read-only users are not allowed to create share links."]);
exit; 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. // Parse POST JSON input.
$input = json_decode(file_get_contents("php://input"), true); $input = json_decode(file_get_contents("php://input"), true);
if (!$input) { if (!$input) {
@@ -1107,6 +1340,23 @@ class FileController
exit; 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 // Convert the provided value+unit into seconds
switch ($unit) { switch ($unit) {
case 'seconds': case 'seconds':
@@ -1349,7 +1599,7 @@ class FileController
// Delegate deletion to the model. // Delegate deletion to the model.
$result = FileModel::deleteTrashFiles($filesToDelete); $result = FileModel::deleteTrashFiles($filesToDelete);
// Build a humanfriendly success or error message // Build a human-friendly success or error message
if (!empty($result['deleted'])) { if (!empty($result['deleted'])) {
$count = count($result['deleted']); $count = count($result['deleted']);
$msg = "Trash item" . ($count === 1 ? "" : "s") . " deleted: " . implode(", ", $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. // Check that the user is not read-only.
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = $this->loadPerms($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) { if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to file tags"]); echo json_encode(["error" => "Read-only users are not allowed to file tags"]);
exit; exit;
@@ -1502,6 +1752,22 @@ class FileController
exit; 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. // Delegate to the model.
$result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete); $result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete);
echo json_encode($result); echo json_encode($result);
@@ -1545,32 +1811,96 @@ class FileController
* @return void Outputs JSON response. * @return void Outputs JSON response.
*/ */
public function getFileList(): void public function getFileList(): void
{ {
header('Content-Type: application/json'); if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
// Ensure user is authenticated. header('Content-Type: application/json; charset=utf-8');
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
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); http_response_code(401);
echo json_encode(["error" => "Unauthorized"]); echo json_encode(['error' => 'Unauthorized']);
exit; return;
} }
// Retrieve the folder from GET; default to "root". if (!is_dir(META_DIR)) {
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; @mkdir(META_DIR, 0775, true);
}
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]); echo json_encode(['error' => 'Invalid folder name.']);
exit; 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); $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'])) { if (isset($result['error'])) {
http_response_code(400); 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 * GET /api/file/getShareLinks.php
@@ -1631,26 +1961,44 @@ class FileController
* POST /api/file/createFile.php * POST /api/file/createFile.php
*/ */
public function createFile(): void public function createFile(): void
{ {
$this->_jsonStart();
try {
if (!$this->_requireAuth()) return;
// Check user permissions (assuming loadUserPermissions() is available).
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username); $userPermissions = $this->loadPerms($username);
if (!empty($userPermissions['readOnly'])) { if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to create files."]); $this->_jsonOut(["error" => "Read-only users are not allowed to create files."], 403);
exit; 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']) { if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code($result['code'] ?? 400); $this->_jsonOut(["error" => "Invalid folder name."], 400); return;
echo json_encode(['success'=>false,'error'=>$result['error']]);
} else {
echo json_encode(['success'=>true]);
} }
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();
} }
} }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,106 @@
<?php <?php
// UserController.php located in src/controllers/ // src/controllers/UserController.php
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php'; require_once PROJECT_ROOT . '/src/models/UserModel.php';
/**
* UserController
* - Hardened CSRF/auth checks (works even when getallheaders() is unavailable)
* - Consistent method checks without breaking existing clients (accepts POST as fallback for some endpoints)
* - Stricter validation & safer defaults
* - Fixed TOTP setup bug for pending-login users
* - Standardized calls to UserModel (proper case)
*/
class UserController class UserController
{ {
/* ---------- Small internal helpers to reduce repetition ---------- */
/** Get headers in lowercase, robust across SAPIs. */
private static function headersLower(): array
{
$headers = function_exists('getallheaders') ? getallheaders() : [];
$out = [];
foreach ($headers as $k => $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( * @OA\Get(
* path="/api/getUsers.php", * path="/api/getUsers.php",
@@ -31,24 +126,15 @@ class UserController
* ) * )
* ) * )
*/ */
public function getUsers() public function getUsers()
{ {
header('Content-Type: application/json'); self::jsonHeaders();
self::requireAdmin();
// 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;
}
// Retrieve users using the model // Retrieve users using the model
$users = userModel::getAllUsers(); $users = UserModel::getAllUsers();
echo json_encode($users); echo json_encode($users);
exit;
} }
/** /**
@@ -84,34 +170,33 @@ class UserController
* ) * )
* ) * )
*/ */
public function addUser() public function addUser()
{ {
// 1) Ensure JSON output and session self::jsonHeaders();
header('Content-Type: application/json'); self::requireMethod(['POST']);
// 1a) Initialize CSRF token if missing // Initialize CSRF token if missing (useful for initial page load)
if (empty($_SESSION['csrf_token'])) { if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_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; $usersFile = USERS_DIR . USERS_FILE;
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1'); $isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
$setupMode = false; $setupMode = false;
if ( if (
$isSetup && (! file_exists($usersFile) $isSetup && (!file_exists($usersFile)
|| filesize($usersFile) === 0 || filesize($usersFile) === 0
|| trim(file_get_contents($usersFile)) === '' || trim(@file_get_contents($usersFile)) === ''
) )
) { ) {
$setupMode = true; $setupMode = true;
} else { } else {
// 3) In non-setup, enforce CSRF + auth checks // Not setup: enforce CSRF + admin auth
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); $h = self::headersLower();
$receivedToken = trim($headersArr['x-csrf-token'] ?? ''); $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']) { if ($receivedToken !== $_SESSION['csrf_token']) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('X-CSRF-Token: ' . $_SESSION['csrf_token']); header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
@@ -122,31 +207,15 @@ class UserController
exit; exit;
} }
// 3b) Must be logged in as admin self::requireAdmin();
if (
empty($_SESSION['authenticated'])
|| $_SESSION['authenticated'] !== true
|| empty($_SESSION['isAdmin'])
|| $_SESSION['isAdmin'] !== true
) {
echo json_encode(["error" => "Unauthorized"]);
exit;
}
} }
// 4) Parse input $data = self::readJson();
$data = json_decode(file_get_contents('php://input'), true) ?: [];
$newUsername = trim($data['username'] ?? ''); $newUsername = trim($data['username'] ?? '');
$newPassword = trim($data['password'] ?? ''); $newPassword = trim($data['password'] ?? '');
// 5) Determine admin flag $isAdmin = $setupMode ? '1' : (!empty($data['isAdmin']) ? '1' : '0');
if ($setupMode) {
$isAdmin = '1';
} else {
$isAdmin = !empty($data['isAdmin']) ? '1' : '0';
}
// 6) Validate fields
if ($newUsername === '' || $newPassword === '') { if ($newUsername === '' || $newPassword === '') {
echo json_encode(["error" => "Username and password required"]); echo json_encode(["error" => "Username and password required"]);
exit; exit;
@@ -157,11 +226,13 @@ class UserController
]); ]);
exit; 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);
$result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
// 8) Return model result
echo json_encode($result); echo json_encode($result);
exit; exit;
} }
@@ -201,54 +272,33 @@ class UserController
* ) * )
* ) * )
*/ */
public function removeUser() 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. $data = self::readJson();
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); $usernameToRemove = trim($data['username'] ?? '');
$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;
}
// Authentication and admin check. if ($usernameToRemove === '') {
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) {
echo json_encode(["error" => "Username is required"]); echo json_encode(["error" => "Username is required"]);
exit; exit;
} }
// Validate the username format.
if (!preg_match(REGEX_USER, $usernameToRemove)) { if (!preg_match(REGEX_USER, $usernameToRemove)) {
echo json_encode(["error" => "Invalid username format"]); echo json_encode(["error" => "Invalid username format"]);
exit; exit;
} }
if (!empty($_SESSION['username']) && $_SESSION['username'] === $usernameToRemove) {
// Prevent removal of the currently logged-in user.
if (isset($_SESSION['username']) && $_SESSION['username'] === $usernameToRemove) {
echo json_encode(["error" => "Cannot remove yourself"]); echo json_encode(["error" => "Cannot remove yourself"]);
exit; exit;
} }
// Delegate the removal logic to the model. $result = UserModel::removeUser($usernameToRemove);
$result = userModel::removeUser($usernameToRemove);
echo json_encode($result); echo json_encode($result);
exit;
} }
/** /**
@@ -269,21 +319,14 @@ class UserController
* ) * )
* ) * )
*/ */
public function getUserPermissions() public function getUserPermissions()
{ {
header('Content-Type: application/json'); self::jsonHeaders();
self::requireAuth();
// Check if the user is authenticated. $permissions = UserModel::getUserPermissions();
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Delegate to the model.
$permissions = userModel::getUserPermissions();
echo json_encode($permissions); echo json_encode($permissions);
exit;
} }
/** /**
@@ -331,42 +374,24 @@ class UserController
* ) * )
* ) * )
*/ */
public function updateUserPermissions() 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. $input = self::readJson();
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);
if (!isset($input['permissions']) || !is_array($input['permissions'])) { if (!isset($input['permissions']) || !is_array($input['permissions'])) {
echo json_encode(["error" => "Invalid input"]); echo json_encode(["error" => "Invalid input"]);
exit; exit;
} }
$permissions = $input['permissions']; $permissions = $input['permissions'];
// Delegate to the model. $result = UserModel::updateUserPermissions($permissions);
$result = userModel::updateUserPermissions($permissions);
echo json_encode($result); echo json_encode($result);
exit;
} }
/** /**
@@ -406,41 +431,25 @@ class UserController
* ) * )
* ) * )
*/ */
public function changePassword() public function changePassword()
{ {
header('Content-Type: application/json'); self::jsonHeaders();
self::requireMethod(['POST']);
// Ensure user is authenticated. self::requireAuth();
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { self::requireCsrf();
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
if (!$username) { if ($username === '') {
echo json_encode(["error" => "No username in session"]); echo json_encode(["error" => "No username in session"]);
exit; exit;
} }
// CSRF token check. $data = self::readJson();
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); $oldPassword = trim($data["oldPassword"] ?? "");
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : ''; $newPassword = trim($data["newPassword"] ?? "");
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"] ?? "");
$confirmPassword = trim($data["confirmPassword"] ?? ""); $confirmPassword = trim($data["confirmPassword"] ?? "");
// Validate input. if ($oldPassword === '' || $newPassword === '' || $confirmPassword === '') {
if (!$oldPassword || !$newPassword || !$confirmPassword) {
echo json_encode(["error" => "All fields are required."]); echo json_encode(["error" => "All fields are required."]);
exit; exit;
} }
@@ -448,10 +457,14 @@ class UserController
echo json_encode(["error" => "New passwords do not match."]); echo json_encode(["error" => "New passwords do not match."]);
exit; 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); echo json_encode($result);
exit;
} }
/** /**
@@ -489,29 +502,15 @@ class UserController
* ) * )
* ) * )
*/ */
public function updateUserPanel() 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. $data = self::readJson();
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);
if (!is_array($data)) { if (!is_array($data)) {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "Invalid input"]); echo json_encode(["error" => "Invalid input"]);
@@ -519,18 +518,16 @@ class UserController
} }
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
if (!$username) { if ($username === '') {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "No username in session"]); echo json_encode(["error" => "No username in session"]);
exit; exit;
} }
// Extract totp_enabled, converting it to boolean.
$totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false; $totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false;
$result = UserModel::updateUserPanel($username, $totp_enabled);
// Delegate to the model.
$result = userModel::updateUserPanel($username, $totp_enabled);
echo json_encode($result); echo json_encode($result);
exit;
} }
/** /**
@@ -558,43 +555,29 @@ class UserController
* ) * )
* ) * )
*/ */
public function disableTOTP() public function disableTOTP()
{ {
header('Content-Type: application/json'); self::jsonHeaders();
// Accept PUT or POST
// Authentication check. self::requireMethod(['PUT', 'POST']);
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { self::requireAuth();
http_response_code(403); self::requireCsrf();
echo json_encode(["error" => "Not authenticated"]);
exit;
}
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
if (empty($username)) { if ($username === '') {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "Username not found in session"]); echo json_encode(["error" => "Username not found in session"]);
exit; exit;
} }
// CSRF token check. $result = UserModel::disableTOTPSecret($username);
$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);
if ($result) { if ($result) {
echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]); echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
} else { } else {
http_response_code(500); http_response_code(500);
echo json_encode(["error" => "Failed to disable TOTP."]); echo json_encode(["error" => "Failed to disable TOTP."]);
} }
exit;
} }
/** /**
@@ -636,61 +619,45 @@ class UserController
* ) * )
* ) * )
*/ */
public function recoverTOTP() public function recoverTOTP()
{ {
header('Content-Type: application/json'); self::jsonHeaders();
self::requireMethod(['POST']);
self::requireCsrf();
// 1) Only allow POST. $userId = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? null);
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;
if (!$userId) { if (!$userId) {
http_response_code(401); 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)) { if (!preg_match(REGEX_USER, $userId)) {
http_response_code(400); 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 = self::readJson();
$inputData = json_decode(file_get_contents("php://input"), true);
$recoveryCode = $inputData['recovery_code'] ?? ''; $recoveryCode = $inputData['recovery_code'] ?? '';
// 6) Delegate to the model. $result = UserModel::recoverTOTP($userId, $recoveryCode);
$result = userModel::recoverTOTP($userId, $recoveryCode);
if ($result['status'] === 'ok') { if (($result['status'] ?? '') === 'ok') {
// 7) Finalize login. // Finalize login
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['authenticated'] = true; $_SESSION['authenticated'] = true;
$_SESSION['username'] = $userId; $_SESSION['username'] = $userId;
unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']); unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']);
echo json_encode(['status' => 'ok']); echo json_encode(['status' => 'ok']);
} else { } 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); http_response_code(429);
} else { } else {
http_response_code(400); http_response_code(400);
} }
echo json_encode($result); echo json_encode($result);
} }
exit;
} }
/** /**
@@ -722,49 +689,33 @@ class UserController
* ) * )
* ) * )
*/ */
public function saveTOTPRecoveryCode() 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'])) { if (empty($_SESSION['username'])) {
http_response_code(401); http_response_code(401);
error_log("totp_saveCode: unauthorized attempt from IP {$_SERVER['REMOTE_ADDR']}"); echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit(json_encode(['status' => 'error', 'message' => 'Unauthorized'])); exit;
} }
// 4) Validate the username format.
$userId = $_SESSION['username']; $userId = $_SESSION['username'];
if (!preg_match(REGEX_USER, $userId)) { if (!preg_match(REGEX_USER, $userId)) {
http_response_code(400); http_response_code(400);
error_log("totp_saveCode: invalid username format: {$userId}"); echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
exit(json_encode(['status' => 'error', 'message' => 'Invalid user identifier'])); exit;
} }
// 5) Delegate to the model. $result = UserModel::saveTOTPRecoveryCode($userId);
$result = userModel::saveTOTPRecoveryCode($userId); if (($result['status'] ?? '') === 'ok') {
if ($result['status'] === 'ok') {
echo json_encode($result); echo json_encode($result);
} else { } else {
http_response_code(500); http_response_code(500);
echo json_encode($result); echo json_encode($result);
} }
exit;
} }
/** /**
@@ -791,43 +742,40 @@ class UserController
* ) * )
* ) * )
*/ */
public function setupTOTP() public function setupTOTP()
{ {
// Allow access if the user is authenticated or pending TOTP. // Allow access if authenticated OR pending TOTP
if (!((isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']))) { if (!( (isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']) )) {
http_response_code(403); http_response_code(403);
exit(json_encode(["error" => "Not authorized to access TOTP setup"])); header('Content-Type: application/json');
} echo 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"]);
exit; exit;
} }
$username = $_SESSION['username'] ?? ''; self::requireCsrf();
if (!$username) {
// 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); http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['error' => 'Username not available for TOTP setup']);
exit; exit;
} }
// Set header for PNG output.
header("Content-Type: image/png"); 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'])) { if (isset($result['error'])) {
http_response_code(500); http_response_code(500);
header('Content-Type: application/json');
echo json_encode(["error" => $result['error']]); echo json_encode(["error" => $result['error']]);
exit; exit;
} }
// Output the QR code image.
echo $result['imageData']; echo $result['imageData'];
exit;
} }
/** /**
@@ -866,11 +814,11 @@ class UserController
* ) * )
* ) * )
*/ */
public function verifyTOTP() public function verifyTOTP()
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';"); header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
header('X-Content-Type-Options: nosniff');
// Rate-limit // Rate-limit
if (!isset($_SESSION['totp_failures'])) { if (!isset($_SESSION['totp_failures'])) {
@@ -890,16 +838,10 @@ class UserController
} }
// CSRF check // CSRF check
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); self::requireCsrf();
$csrfHeader = $headersArr['x-csrf-token'] ?? '';
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
exit;
}
// Parse & validate input // Parse & validate input
$inputData = json_decode(file_get_contents("php://input"), true); $inputData = self::readJson();
$code = trim($inputData['totp_code'] ?? ''); $code = trim($inputData['totp_code'] ?? '');
if (!preg_match('/^\d{6}$/', $code)) { if (!preg_match('/^\d{6}$/', $code)) {
http_response_code(400); http_response_code(400);
@@ -916,11 +858,11 @@ class UserController
\RobThree\Auth\Algorithm::Sha1 \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'])) { if (isset($_SESSION['pending_login_user'])) {
$username = $_SESSION['pending_login_user']; $username = $_SESSION['pending_login_user'];
$pendingSecret = $_SESSION['pending_login_secret'] ?? null; $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)) { if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
$_SESSION['totp_failures']++; $_SESSION['totp_failures']++;
@@ -939,13 +881,14 @@ class UserController
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
$all = json_decode($dec, true) ?: []; $all = json_decode($dec, true) ?: [];
} }
$perms = loadUserPermissions($username);
$all[$token] = [ $all[$token] = [
'username' => $username, 'username' => $username,
'expiry' => $expiry, 'expiry' => $expiry,
'isAdmin' => ((int)userModel::getUserRole($username) === 1), 'isAdmin' => ((int)UserModel::getUserRole($username) === 1),
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false, 'folderOnly' => $perms['folderOnly'] ?? false,
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false, 'readOnly' => $perms['readOnly'] ?? false,
'disableUpload' => loadUserPermissions($username)['disableUpload'] ?? false 'disableUpload' => $perms['disableUpload'] ?? false
]; ];
file_put_contents( file_put_contents(
$tokFile, $tokFile,
@@ -957,17 +900,16 @@ class UserController
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true); setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
} }
// === Finalize login into session exactly as finalizeLogin() would === // Finalize login
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['authenticated'] = true; $_SESSION['authenticated'] = true;
$_SESSION['username'] = $username; $_SESSION['username'] = $username;
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1); $_SESSION['isAdmin'] = ((int)UserModel::getUserRole($username) === 1);
$perms = loadUserPermissions($username); $perms = loadUserPermissions($username);
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false; $_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
$_SESSION['readOnly'] = $perms['readOnly'] ?? false; $_SESSION['readOnly'] = $perms['readOnly'] ?? false;
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false; $_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
// Clean up pending markers
unset( unset(
$_SESSION['pending_login_user'], $_SESSION['pending_login_user'],
$_SESSION['pending_login_secret'], $_SESSION['pending_login_secret'],
@@ -975,7 +917,6 @@ class UserController
$_SESSION['totp_failures'] $_SESSION['totp_failures']
); );
// Send back full login payload
echo json_encode([ echo json_encode([
'status' => 'ok', 'status' => 'ok',
'success' => 'Login successful', 'success' => 'Login successful',
@@ -990,13 +931,13 @@ class UserController
// Setup/verification flow (not pending) // Setup/verification flow (not pending)
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
if (!$username) { if ($username === '') {
http_response_code(400); http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']); echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
exit; exit;
} }
$totpSecret = userModel::getTOTPSecret($username); $totpSecret = UserModel::getTOTPSecret($username);
if (!$totpSecret) { if (!$totpSecret) {
http_response_code(500); http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']); echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
@@ -1010,34 +951,22 @@ class UserController
exit; exit;
} }
// Successful setup/verification
unset($_SESSION['totp_failures']); unset($_SESSION['totp_failures']);
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']); echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
exit;
} }
/**
* Upload profile picture (multipart/form-data)
*/
public function uploadPicture() public function uploadPicture()
{ {
header('Content-Type: application/json'); self::jsonHeaders();
// 1) Auth check // Auth & CSRF
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { self::requireAuth();
http_response_code(401); self::requireCsrf();
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
// 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) { if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']); echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
@@ -1045,7 +974,7 @@ class UserController
} }
$file = $_FILES['profile_picture']; $file = $_FILES['profile_picture'];
// 4) Validate MIME & size // Validate MIME & size
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif']; $allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
$finfo = finfo_open(FILEINFO_MIME_TYPE); $finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']); $mime = finfo_file($finfo, $file['tmp_name']);
@@ -1061,32 +990,29 @@ class UserController
exit; exit;
} }
// 5) Destination under public/uploads/profile_pics // Destination
$uploadDir = UPLOAD_DIR . '/profile_pics'; $uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) { if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']); echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
exit; exit;
} }
// 6) Move file
$ext = $allowed[$mime]; $ext = $allowed[$mime];
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']); $user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']);
$filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext; $filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$dest = "$uploadDir/$filename"; $dest = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $dest)) { if (!move_uploaded_file($file['tmp_name'], $dest)) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save file']); echo json_encode(['success' => false, 'error' => 'Failed to save file']);
exit; exit;
} }
// 7) Build public URL // Assuming /uploads maps to UPLOAD_DIR publicly
$url = '/uploads/profile_pics/' . $filename; $url = '/uploads/profile_pics/' . $filename;
// ─── THIS IS WHERE WE PERSIST INTO users.txt ───
$result = UserModel::setProfilePicture($_SESSION['username'], $url); $result = UserModel::setProfilePicture($_SESSION['username'], $url);
if (!$result['success']) { if (!($result['success'] ?? false)) {
// on failure, remove the file we just wrote
@unlink($dest); @unlink($dest);
http_response_code(500); http_response_code(500);
echo json_encode([ echo json_encode([
@@ -1095,9 +1021,7 @@ class UserController
]); ]);
exit; exit;
} }
// ─────────────────────────────────────────────────
// 8) Return success
echo json_encode(['success' => true, 'url' => $url]); echo json_encode(['success' => true, 'url' => $url]);
exit; exit;
} }

View File

@@ -6,25 +6,60 @@ require_once PROJECT_ROOT . '/config/config.php';
class AdminModel 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 * @param string $val
* @return int * @return int Bytes (rounded)
*/ */
private static function parseSize(string $val): int private static function parseSize(string $val): int
{ {
$unit = strtolower(substr($val, -1)); $val = trim($val);
$num = (int) rtrim($val, 'bkmgtpezyBKMGTPESY'); if ($val === '') {
switch ($unit) { return 0;
case 'g':
return $num * 1024 ** 3;
case 'm':
return $num * 1024 ** 2;
case 'k':
return $num * 1024;
default:
return $num;
} }
// 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 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']) $oidcDisabled = isset($configUpdate['loginOptions']['disableOIDCLogin'])
? (bool)$configUpdate['loginOptions']['disableOIDCLogin'] ? (bool)$configUpdate['loginOptions']['disableOIDCLogin']
: true; // default to disabled when not present : true; // default to disabled when not present
if (!$oidcDisabled) { if (!$oidcDisabled) {
$oidc = $configUpdate['oidc'] ?? []; $oidc = $configUpdate['oidc'] ?? [];
$required = ['providerUrl','clientId','clientSecret','redirectUri']; $required = ['providerUrl','clientId','clientSecret','redirectUri'];
foreach ($required as $k) { foreach ($required as $k) {
if (empty($oidc[$k]) || !is_string($oidc[$k])) { if (empty($oidc[$k]) || !is_string($oidc[$k])) {
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."]; return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."];
} }
} }
} }
@@ -72,7 +112,7 @@ class AdminModel
$configUpdate['sharedMaxUploadSize'] = $sms; $configUpdate['sharedMaxUploadSize'] = $sms;
} }
// ── NEW: normalize authBypass & authHeaderName ───────────────────────── // Normalize authBypass & authHeaderName
if (!isset($configUpdate['loginOptions']['authBypass'])) { if (!isset($configUpdate['loginOptions']['authBypass'])) {
$configUpdate['loginOptions']['authBypass'] = false; $configUpdate['loginOptions']['authBypass'] = false;
} }
@@ -85,10 +125,8 @@ class AdminModel
) { ) {
$configUpdate['loginOptions']['authHeaderName'] = 'X-Remote-User'; $configUpdate['loginOptions']['authHeaderName'] = 'X-Remote-User';
} else { } else {
$configUpdate['loginOptions']['authHeaderName'] = $configUpdate['loginOptions']['authHeaderName'] = trim($configUpdate['loginOptions']['authHeaderName']);
trim($configUpdate['loginOptions']['authHeaderName']);
} }
// ───────────────────────────────────────────────────────────────────────────
// Convert configuration to JSON. // Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT); $plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
@@ -109,7 +147,7 @@ class AdminModel
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) { if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
// Attempt a cleanup: delete the old file and try again. // Attempt a cleanup: delete the old file and try again.
if (file_exists($configFile)) { if (file_exists($configFile)) {
unlink($configFile); @unlink($configFile);
} }
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) { if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
error_log("AdminModel::updateConfig: Failed to write configuration even after deletion."); error_log("AdminModel::updateConfig: Failed to write configuration even after deletion.");
@@ -130,13 +168,15 @@ class AdminModel
public static function getConfig(): array public static function getConfig(): array
{ {
$configFile = USERS_DIR . 'adminConfig.json'; $configFile = USERS_DIR . 'adminConfig.json';
if (file_exists($configFile)) { if (file_exists($configFile)) {
$encryptedContent = file_get_contents($configFile); $encryptedContent = file_get_contents($configFile);
$decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']); $decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']);
if ($decryptedContent === false) { if ($decryptedContent === false) {
http_response_code(500); // Do not set HTTP status here; let the controller decide.
return ["error" => "Failed to decrypt configuration."]; return ["error" => "Failed to decrypt configuration."];
} }
$config = json_decode($decryptedContent, true); $config = json_decode($decryptedContent, true);
if (!is_array($config)) { if (!is_array($config)) {
$config = []; $config = [];
@@ -144,7 +184,7 @@ class AdminModel
// Normalize login options if missing // Normalize login options if missing
if (!isset($config['loginOptions'])) { 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'] = [ $config['loginOptions'] = [
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false, 'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false, 'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
@@ -152,13 +192,14 @@ class AdminModel
]; ];
unset($config['disableFormLogin'], $config['disableBasicAuth'], $config['disableOIDCLogin']); unset($config['disableFormLogin'], $config['disableBasicAuth'], $config['disableOIDCLogin']);
} else { } else {
// normalize booleans; default OIDC to true (disabled) if missing // Normalize booleans; default OIDC to true (disabled) if missing
$lo = &$config['loginOptions']; $lo = &$config['loginOptions'];
$lo['disableFormLogin'] = isset($lo['disableFormLogin']) ? (bool)$lo['disableFormLogin'] : false; $lo['disableFormLogin'] = isset($lo['disableFormLogin']) ? (bool)$lo['disableFormLogin'] : false;
$lo['disableBasicAuth'] = isset($lo['disableBasicAuth']) ? (bool)$lo['disableBasicAuth'] : false; $lo['disableBasicAuth'] = isset($lo['disableBasicAuth']) ? (bool)$lo['disableBasicAuth'] : false;
$lo['disableOIDCLogin'] = isset($lo['disableOIDCLogin']) ? (bool)$lo['disableOIDCLogin'] : true; $lo['disableOIDCLogin'] = isset($lo['disableOIDCLogin']) ? (bool)$lo['disableOIDCLogin'] : true;
} }
// Ensure OIDC structure exists
if (!isset($config['oidc']) || !is_array($config['oidc'])) { if (!isset($config['oidc']) || !is_array($config['oidc'])) {
$config['oidc'] = [ $config['oidc'] = [
'providerUrl' => '', 'providerUrl' => '',
@@ -174,6 +215,7 @@ class AdminModel
} }
} }
// Normalize authBypass & authHeaderName
if (!array_key_exists('authBypass', $config['loginOptions'])) { if (!array_key_exists('authBypass', $config['loginOptions'])) {
$config['loginOptions']['authBypass'] = false; $config['loginOptions']['authBypass'] = false;
} else { } else {
@@ -191,38 +233,41 @@ class AdminModel
if (!isset($config['globalOtpauthUrl'])) { if (!isset($config['globalOtpauthUrl'])) {
$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"; $config['header_title'] = "FileRise";
} }
if (!isset($config['enableWebDAV'])) { if (!isset($config['enableWebDAV'])) {
$config['enableWebDAV'] = false; $config['enableWebDAV'] = false;
} }
// Default sharedMaxUploadSize to 50MB or TOTAL_UPLOAD_SIZE if smaller
if (!isset($config['sharedMaxUploadSize'])) { // sharedMaxUploadSize: default if missing; clamp if present
$defaultSms = min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE)); $maxBytes = self::parseSize(TOTAL_UPLOAD_SIZE);
$config['sharedMaxUploadSize'] = $defaultSms; 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; 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))
];
} }
} }

View File

@@ -5,6 +5,42 @@ require_once PROJECT_ROOT . '/config/config.php';
class FileModel { 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. * Copies files from a source folder to a destination folder, updating metadata if available.
* *
@@ -15,36 +51,36 @@ class FileModel {
*/ */
public static function copyFiles($sourceFolder, $destinationFolder, $files) { public static function copyFiles($sourceFolder, $destinationFolder, $files) {
$errors = []; $errors = [];
$baseDir = rtrim(UPLOAD_DIR, '/\\');
// Build source and destination directories. list($sourceDir, $err) = self::resolveFolderPath($sourceFolder, false);
$sourceDir = ($sourceFolder === 'root') if ($err) return ["error" => $err];
? $baseDir . DIRECTORY_SEPARATOR list($destDir, $err) = self::resolveFolderPath($destinationFolder, true);
: $baseDir . DIRECTORY_SEPARATOR . trim($sourceFolder, "/\\ ") . DIRECTORY_SEPARATOR; if ($err) return ["error" => $err];
$destDir = ($destinationFolder === 'root')
? $baseDir . DIRECTORY_SEPARATOR
: $baseDir . DIRECTORY_SEPARATOR . trim($destinationFolder, "/\\ ") . DIRECTORY_SEPARATOR;
// Get metadata file paths. $sourceDir .= DIRECTORY_SEPARATOR;
$srcMetaFile = self::getMetadataFilePath($sourceFolder); $destDir .= DIRECTORY_SEPARATOR;
// Metadata paths
$srcMetaFile = self::getMetadataFilePath($sourceFolder);
$destMetaFile = self::getMetadataFilePath($destinationFolder); $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) : []; $destMetadata = file_exists($destMetaFile) ? (json_decode(file_get_contents($destMetaFile), true) ?: []) : [];
// Define a safe file name pattern.
$safeFileNamePattern = REGEX_FILE_NAME; $safeFileNamePattern = REGEX_FILE_NAME;
$actor = $_SESSION['username'] ?? 'Unknown';
$now = date(DATE_TIME_FORMAT);
foreach ($files as $fileName) { foreach ($files as $fileName) {
// Get the clean file name.
$originalName = basename(trim($fileName)); $originalName = basename(trim($fileName));
$basename = $originalName; $basename = $originalName;
if (!preg_match($safeFileNamePattern, $basename)) { if (!preg_match($safeFileNamePattern, $basename)) {
$errors[] = "$basename has an invalid name."; $errors[] = "$basename has an invalid name.";
continue; continue;
} }
$srcPath = $sourceDir . $originalName; $srcPath = $sourceDir . $originalName;
$destPath = $destDir . $basename; $destPath = $destDir . $basename;
clearstatcache(); clearstatcache();
@@ -53,11 +89,10 @@ class FileModel {
continue; 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)) { if (file_exists($destPath)) {
$uniqueName = self::getUniqueFileName($destDir, $basename); $basename = self::getUniqueFileName($destDir, $basename);
$basename = $uniqueName; $destPath = $destDir . $basename;
$destPath = $destDir . $uniqueName;
} }
if (!copy($srcPath, $destPath)) { if (!copy($srcPath, $destPath)) {
@@ -65,21 +100,27 @@ class FileModel {
continue; continue;
} }
// Update destination metadata if metadata exists in source. // Carry over non-ownership fields (e.g., tags), but stamp new ownership/timestamps
if (isset($srcMetadata[$originalName])) { $tags = [];
$destMetadata[$basename] = $srcMetadata[$originalName]; 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."; $errors[] = "Failed to update destination metadata.";
} }
if (empty($errors)) { return empty($errors)
return ["success" => "Files copied successfully"]; ? ["success" => "Files copied successfully"]
} else { : ["error" => implode("; ", $errors)];
return ["error" => implode("; ", $errors)];
}
} }
/** /**
@@ -130,12 +171,10 @@ class FileModel {
*/ */
public static function deleteFiles($folder, $files) { public static function deleteFiles($folder, $files) {
$errors = []; $errors = [];
$baseDir = rtrim(UPLOAD_DIR, '/\\');
// Determine the upload directory. list($uploadDir, $err) = self::resolveFolderPath($folder, false);
$uploadDir = ($folder === 'root') if ($err) return ["error" => $err];
? $baseDir . DIRECTORY_SEPARATOR $uploadDir .= DIRECTORY_SEPARATOR;
: $baseDir . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR;
// Setup the Trash folder and metadata. // Setup the Trash folder and metadata.
$trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; $trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR;
@@ -160,7 +199,6 @@ class FileModel {
} }
$movedFiles = []; $movedFiles = [];
// Define a safe file name pattern.
$safeFileNamePattern = REGEX_FILE_NAME; $safeFileNamePattern = REGEX_FILE_NAME;
foreach ($files as $fileName) { foreach ($files as $fileName) {
@@ -176,9 +214,8 @@ class FileModel {
// Check if file exists. // Check if file exists.
if (file_exists($filePath)) { if (file_exists($filePath)) {
// Append a timestamp to create a unique trash file name. // Unique trash name (timestamp + random)
$timestamp = time(); $trashFileName = $basename . '_' . time() . '_' . bin2hex(random_bytes(4));
$trashFileName = $basename . "_" . $timestamp;
if (rename($filePath, $trashDir . $trashFileName)) { if (rename($filePath, $trashDir . $trashFileName)) {
$movedFiles[] = $basename; $movedFiles[] = $basename;
// Record trash metadata for possible restoration. // Record trash metadata for possible restoration.
@@ -187,11 +224,9 @@ class FileModel {
'originalFolder' => $uploadDir, 'originalFolder' => $uploadDir,
'originalName' => $basename, 'originalName' => $basename,
'trashName' => $trashFileName, 'trashName' => $trashFileName,
'trashedAt' => $timestamp, 'trashedAt' => time(),
'uploaded' => isset($folderMetadata[$basename]['uploaded']) 'uploaded' => $folderMetadata[$basename]['uploaded'] ?? "Unknown",
? $folderMetadata[$basename]['uploaded'] : "Unknown", 'uploader' => $folderMetadata[$basename]['uploader'] ?? "Unknown",
'uploader' => isset($folderMetadata[$basename]['uploader'])
? $folderMetadata[$basename]['uploader'] : "Unknown",
'deletedBy' => $_SESSION['username'] ?? "Unknown" 'deletedBy' => $_SESSION['username'] ?? "Unknown"
]; ];
} else { } else {
@@ -205,7 +240,7 @@ class FileModel {
} }
// Save updated trash metadata. // 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. // Remove deleted file entries from folder metadata.
if (file_exists($metadataFile)) { if (file_exists($metadataFile)) {
@@ -216,7 +251,7 @@ class FileModel {
unset($metadata[$delFile]); 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);
} }
} }
@@ -227,7 +262,7 @@ class FileModel {
} }
} }
/** /**
* Moves files from a source folder to a destination folder and updates metadata. * 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). * @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) { public static function moveFiles($sourceFolder, $destinationFolder, $files) {
$errors = []; $errors = [];
$baseDir = rtrim(UPLOAD_DIR, '/\\');
// Build source and destination directories. list($sourceDir, $err) = self::resolveFolderPath($sourceFolder, false);
$sourceDir = ($sourceFolder === 'root') if ($err) return ["error" => $err];
? $baseDir . DIRECTORY_SEPARATOR list($destDir, $err) = self::resolveFolderPath($destinationFolder, true);
: $baseDir . DIRECTORY_SEPARATOR . trim($sourceFolder, "/\\ ") . DIRECTORY_SEPARATOR; if ($err) return ["error" => $err];
$destDir = ($destinationFolder === 'root')
? $baseDir . DIRECTORY_SEPARATOR
: $baseDir . DIRECTORY_SEPARATOR . trim($destinationFolder, "/\\ ") . DIRECTORY_SEPARATOR;
// Ensure destination directory exists. $sourceDir .= DIRECTORY_SEPARATOR;
if (!is_dir($destDir)) { $destDir .= DIRECTORY_SEPARATOR;
if (!mkdir($destDir, 0775, true)) {
return ["error" => "Could not create destination folder"];
}
}
// Get metadata file paths. // Get metadata file paths.
$srcMetaFile = self::getMetadataFilePath($sourceFolder); $srcMetaFile = self::getMetadataFilePath($sourceFolder);
$destMetaFile = self::getMetadataFilePath($destinationFolder); $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) : []; $destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : [];
if (!is_array($srcMetadata)) { if (!is_array($srcMetadata)) {
$srcMetadata = []; $srcMetadata = [];
@@ -268,7 +295,6 @@ class FileModel {
} }
$movedFiles = []; $movedFiles = [];
// Define a safe file name pattern.
$safeFileNamePattern = REGEX_FILE_NAME; $safeFileNamePattern = REGEX_FILE_NAME;
foreach ($files as $fileName) { foreach ($files as $fileName) {
@@ -312,10 +338,10 @@ class FileModel {
} }
// Write back updated metadata. // 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."; $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."; $errors[] = "Failed to update destination metadata.";
} }
@@ -326,7 +352,7 @@ class FileModel {
} }
} }
/** /**
* Renames a file within a given folder and updates folder metadata. * 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). * @param string $folder The folder where the file is located (or "root" for the base directory).
@@ -335,10 +361,9 @@ class FileModel {
* @return array An associative array with either "success" (and newName) or "error" message. * @return array An associative array with either "success" (and newName) or "error" message.
*/ */
public static function renameFile($folder, $oldName, $newName) { public static function renameFile($folder, $oldName, $newName) {
// Determine the directory path. list($directory, $err) = self::resolveFolderPath($folder, false);
$directory = ($folder !== 'root') if ($err) return ["error" => $err];
? rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR $directory .= DIRECTORY_SEPARATOR;
: UPLOAD_DIR;
// Sanitize file names. // Sanitize file names.
$oldName = basename(trim($oldName)); $oldName = basename(trim($oldName));
@@ -374,7 +399,7 @@ class FileModel {
if (isset($metadata[$oldName])) { if (isset($metadata[$oldName])) {
$metadata[$newName] = $metadata[$oldName]; $metadata[$newName] = $metadata[$oldName];
unset($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]; return ["success" => "File renamed successfully", "newName" => $newName];
@@ -383,96 +408,87 @@ class FileModel {
} }
} }
/* /*
* Save a files contents *and* record its metadata, including who uploaded it. * Save a files contents *and* record its metadata, including who uploaded it.
* *
* @param string $folder Folder key (e.g. "root" or "invoices/2025") * @param string $folder Folder key (e.g. "root" or "invoices/2025")
* @param string $fileName Basename of the file * @param string $fileName Basename of the file
* @param resource|string $content File contents (stream or string) * @param resource|string $content File contents (stream or string)
* @param string|null $uploader Username of uploader (if null, falls back to session) * @param string|null $uploader Username of uploader (if null, falls back to session)
* @return array ["success"=>"…"] or ["error"=>"…"] * @return array ["success"=>"…"] or ["error"=>"…"]
*/ */
public static function saveFile(string $folder, string $fileName, $content, ?string $uploader = null): array { public static function saveFile(string $folder, string $fileName, $content, ?string $uploader = null): array {
// Sanitize inputs $folder = trim($folder) ?: 'root';
$folder = trim($folder) ?: 'root'; $fileName = basename(trim($fileName));
$fileName = basename(trim($fileName));
// Validate folder name if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { return ["error" => "Invalid folder name"];
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"];
} }
stream_copy_to_stream($content, $out); if (!preg_match(REGEX_FILE_NAME, $fileName)) {
fclose($out); return ["error" => "Invalid file name"];
} else {
if (file_put_contents($filePath, (string)$content) === false) {
return ["error" => "Error saving file"];
} }
}
// ——— UPDATE METADATA ——— $baseDirReal = realpath(UPLOAD_DIR);
$metadataKey = strtolower($folder) === "root" ? "root" : $folder; if ($baseDirReal === false) {
$metadataFileName = str_replace(['/', '\\', ' '], '-', trim($metadataKey)) . '_metadata.json'; return ["error" => "Server misconfiguration"];
$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;
} }
$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. * Validates and retrieves information needed to download a file.
* *
* @param string $folder The folder from which to download (e.g., "root" or a subfolder). * @param string $folder The folder from which to download (e.g., "root" or a subfolder).
@@ -520,15 +536,19 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
return ["error" => "File not found."]; return ["error" => "File not found."];
} }
// Get the MIME type. // Get the MIME type with safe fallback.
$mimeType = mime_content_type($realFilePath); $mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null;
if (!$mimeType) {
$mimeType = 'application/octet-stream';
}
return [ return [
"filePath" => $realFilePath, "filePath" => $realFilePath,
"mimeType" => $mimeType "mimeType" => $mimeType
]; ];
} }
/** /**
* Creates a ZIP archive of the specified files from a given folder. * 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). * @param string $folder The folder from which to zip the files (e.g., "root" or a subfolder).
@@ -591,7 +611,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
return ["zipPath" => $tempZip]; return ["zipPath" => $tempZip];
} }
/** /**
* Extracts ZIP archives from the specified folder. * 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). * @param string $folder The folder from which ZIP files will be extracted (e.g., "root" or a subfolder).
@@ -603,109 +623,124 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
$allSuccess = true; $allSuccess = true;
$extractedFiles = []; $extractedFiles = [];
// Determine the base upload directory and build the folder path.
$baseDir = realpath(UPLOAD_DIR); $baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) { if ($baseDir === false) {
return ["error" => "Uploads directory not configured correctly."]; return ["error" => "Uploads directory not configured correctly."];
} }
if (strtolower($folder) === "root" || trim($folder) === "") { // Build target dir
if (strtolower(trim($folder) ?: '') === "root") {
$relativePath = ""; $relativePath = "";
} else { } else {
$parts = explode('/', trim($folder, "/\\")); $parts = explode('/', trim($folder, "/\\"));
foreach ($parts as $part) { 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."]; return ["error" => "Invalid folder name."];
} }
} }
$relativePath = implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR; $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); $folderPathReal = realpath($folderPath);
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) { if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
return ["error" => "Folder not found."]; return ["error" => "Folder not found."];
} }
// Prepare metadata. // Prepare metadata container
// Reuse our helper method if available; otherwise, re-create the logic. $metadataFile = self::getMetadataFilePath($folder);
$metadataFile = self::getMetadataFilePath($folder); $destMetadata = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
$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.
$safeFileNamePattern = REGEX_FILE_NAME; $safeFileNamePattern = REGEX_FILE_NAME;
$actor = $_SESSION['username'] ?? 'Unknown';
$now = date(DATE_TIME_FORMAT);
// Process each ZIP file.
foreach ($files as $zipFileName) { foreach ($files as $zipFileName) {
$originalName = basename(trim($zipFileName)); $zipBase = basename(trim($zipFileName));
// Process only .zip files. if (strtolower(substr($zipBase, -4)) !== '.zip') {
if (strtolower(substr($originalName, -4)) !== '.zip') {
continue; continue;
} }
if (!preg_match($safeFileNamePattern, $originalName)) { if (!preg_match($safeFileNamePattern, $zipBase)) {
$errors[] = "$originalName has an invalid name."; $errors[] = "$zipBase has an invalid name.";
$allSuccess = false; $allSuccess = false;
continue; continue;
} }
$zipFilePath = $folderPathReal . DIRECTORY_SEPARATOR . $originalName; $zipFilePath = $folderPathReal . DIRECTORY_SEPARATOR . $zipBase;
if (!file_exists($zipFilePath)) { if (!file_exists($zipFilePath)) {
$errors[] = "$originalName does not exist in folder."; $errors[] = "$zipBase does not exist in folder.";
$allSuccess = false; $allSuccess = false;
continue; continue;
} }
$zip = new ZipArchive(); $zip = new ZipArchive();
if ($zip->open($zipFilePath) !== TRUE) { 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; $allSuccess = false;
continue; continue;
} }
// Attempt extraction. // Minimal Zip Slip guard: fail if any entry looks unsafe
if (!$zip->extractTo($folderPathReal)) { $unsafe = false;
$errors[] = "Failed to extract $originalName."; 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; $allSuccess = false;
} else { continue;
// Collect extracted file names from this archive. }
for ($i = 0; $i < $zip->numFiles; $i++) {
$entryName = $zip->getNameIndex($i); // Extract safely (whole archive) after precheck
$extractedFileName = basename($entryName); if (!$zip->extractTo($folderPathReal)) {
if ($extractedFileName) { $errors[] = "Failed to extract $zipBase.";
$extractedFiles[] = $extractedFileName; $allSuccess = false;
} $zip->close();
} continue;
// Update metadata for each extracted file if the ZIP has metadata. }
if (isset($srcMetadata[$originalName])) {
$zipMeta = $srcMetadata[$originalName]; // Stamp metadata for extracted regular files
for ($i = 0; $i < $zip->numFiles; $i++) { for ($i = 0; $i < $zip->numFiles; $i++) {
$entryName = $zip->getNameIndex($i); $entryName = $zip->getNameIndex($i);
$extractedFileName = basename($entryName); if ($entryName === false) continue;
if ($extractedFileName) {
$destMetadata[$extractedFileName] = $zipMeta; $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(); $zip->close();
} }
// Save updated metadata. if (file_put_contents($metadataFile, json_encode($destMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) {
if (file_put_contents($metadataFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) {
$errors[] = "Failed to update metadata."; $errors[] = "Failed to update metadata.";
$allSuccess = false; $allSuccess = false;
} }
if ($allSuccess) { return $allSuccess
return ["success" => true, "extractedFiles" => $extractedFiles]; ? ["success" => true, "extractedFiles" => $extractedFiles]
} else { : ["success" => false, "error" => implode(" ", $errors)];
return ["success" => false, "error" => implode(" ", $errors)];
}
} }
/** /**
@@ -726,12 +761,12 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
return $shareLinks[$token]; return $shareLinks[$token];
} }
/** /**
* Creates a share link for a file. * Creates a share link for a file.
* *
* @param string $folder The folder containing the shared file (or "root"). * @param string $folder The folder containing the shared file (or "root").
* @param string $file The name of the file being shared. * @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. * @param string $password Optional password protecting the share.
* @return array Returns an associative array with keys "token" and "expires" on success, * @return array Returns an associative array with keys "token" and "expires" on success,
* or "error" on failure. * or "error" on failure.
@@ -741,6 +776,11 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name."]; 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). // Generate a secure token (32 hex characters).
$token = bin2hex(random_bytes(16)); $token = bin2hex(random_bytes(16));
@@ -779,14 +819,14 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
]; ];
// Save the updated share links. // 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]; return ["token" => $token, "expires" => $expires];
} else { } else {
return ["error" => "Could not save share link."]; return ["error" => "Could not save share link."];
} }
} }
/** /**
* Retrieves and enriches trash records from the trash metadata file. * Retrieves and enriches trash records from the trash metadata file.
* *
* @return array An array of trash items. * @return array An array of trash items.
@@ -834,7 +874,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
return $trashItems; return $trashItems;
} }
/** /**
* Restores files from Trash based on an array of trash file identifiers. * 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). * @param array $trashFiles An array of trash file names (i.e. the 'trashName' fields).
@@ -963,7 +1003,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
"uploader" => isset($record['uploader']) ? $record['uploader'] : "Unknown" "uploader" => isset($record['uploader']) ? $record['uploader'] : "Unknown"
]; ];
$metadata[$originalName] = $restoredMeta; $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]); unset($trashData[$recordKey]);
} else { } else {
$errors[] = "Failed to restore $originalName."; $errors[] = "Failed to restore $originalName.";
@@ -974,7 +1014,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
} }
// Write back updated trash metadata. // 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)) { if (empty($errors)) {
return ["success" => "Items restored: " . implode(", ", $restoredItems), "restored" => $restoredItems]; return ["success" => "Items restored: " . implode(", ", $restoredItems), "restored" => $restoredItems];
@@ -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. * Deletes trash items based on an array of trash file identifiers.
* *
* @param array $filesToDelete An array of trash file names (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. // 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)) { if (empty($errors)) {
return ["deleted" => $deletedFiles]; return ["deleted" => $deletedFiles];
@@ -1054,7 +1094,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
} }
} }
/** /**
* Retrieves file tags from the createdTags.json metadata file. * 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. * @return array An array of tags. Returns an empty array if the file doesn't exist or is not readable.
@@ -1084,7 +1124,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
return $jsonData; return $jsonData;
} }
/** /**
* Saves tag data for a specified file and updates the global tags. * 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). * @param string $folder The folder where the file is located (e.g., "root" or a subfolder).
@@ -1095,14 +1135,20 @@ 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. * @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 { 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'; $folder = trim($folder) ?: 'root';
$metadataFile = ""; $file = basename(trim($file));
if (strtolower($folder) === "root") { if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
$metadataFile = META_DIR . "root_metadata.json"; return ["error" => "Invalid folder name."];
} else {
$metadataFile = META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
} }
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. // Load existing metadata for this folder.
$metadata = []; $metadata = [];
@@ -1116,7 +1162,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
} }
$metadata[$file]['tags'] = $tags; $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."]; return ["error" => "Failed to save tag data for file metadata."];
} }
@@ -1151,16 +1197,17 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
$globalTags[] = $tag; $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 ["error" => "Failed to save global tags."];
} }
return ["success" => "Tag data saved successfully.", "globalTags" => $globalTags]; return ["success" => "Tag data saved successfully.", "globalTags" => $globalTags];
} }
/** /**
* Retrieves the list of files in a given folder, enriched with metadata, along with global tags. * 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). * @param string $folder The folder name (e.g., "root" or a subfolder).
@@ -1323,7 +1370,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
return false; return false;
} }
unset($links[$token]); 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; 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 public static function createFile(string $folder, string $filename, string $uploader): array
{ {
// 1) basic validation // 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]; return ['success'=>false,'error'=>'Invalid filename','code'=>400];
} }
// 2) build target path // 2) resolve target folder
$base = UPLOAD_DIR; list($baseDir, $err) = self::resolveFolderPath($folder, true);
if ($folder !== 'root') { if ($err) {
$base = rtrim(UPLOAD_DIR, '/\\') return ['success'=>false, 'error'=>$err, 'code'=>($err === 'Invalid folder name.' ? 400 : 500)];
. DIRECTORY_SEPARATOR . $folder
. DIRECTORY_SEPARATOR;
} }
if (!is_dir($base) && !mkdir($base, 0775, true)) {
return ['success'=>false,'error'=>'Cannot create folder','code'=>500]; $path = $baseDir . DIRECTORY_SEPARATOR . $filename;
}
$path = $base . $filename;
// 3) no overwrite // 3) no overwrite
if (file_exists($path)) { if (file_exists($path)) {
@@ -1360,12 +1404,12 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
} }
// 4) touch the file // 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]; return ['success'=>false,'error'=>'Could not create file','code'=>500];
} }
// 5) write metadata // 5) write metadata
$metaKey = ($folder === 'root') ? 'root' : $folder; $metaKey = (strtolower($folder) === 'root' || trim($folder) === '') ? 'root' : $folder;
$metaName = str_replace(['/', '\\', ' '], '-', $metaKey) . '_metadata.json'; $metaName = str_replace(['/', '\\', ' '], '-', $metaKey) . '_metadata.json';
$metaPath = META_DIR . $metaName; $metaPath = META_DIR . $metaName;
@@ -1375,12 +1419,14 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
$collection = json_decode($json, true) ?: []; $collection = json_decode($json, true) ?: [];
} }
$now = date(DATE_TIME_FORMAT);
$collection[$filename] = [ $collection[$filename] = [
'uploaded' => date(DATE_TIME_FORMAT), 'uploaded' => $now,
'modified' => $now,
'uploader' => $uploader '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]; return ['success'=>false,'error'=>'Failed to update metadata','code'=>500];
} }

View File

@@ -6,58 +6,61 @@ require_once PROJECT_ROOT . '/config/config.php';
class FolderModel 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 $folder Relative folder or "root"
* @param string $parent (Optional) The parent folder name. Defaults to empty. * @param bool $create Create the folder if missing
* @return array Returns an array with a "success" key if the folder was created, * @return array [string|null $realPath, string $relative, string|null $error]
* or an "error" key if an error occurred.
*/ */
public static function createFolder(string $folderName, string $parent = ""): array private static function resolveFolderPath(string $folder, bool $create = false): array
{ {
$folderName = trim($folderName); $folder = trim($folder) ?: 'root';
$parent = trim($parent); $relative = 'root';
// Validate folder name (only letters, numbers, underscores, dashes, and spaces allowed). $base = realpath(UPLOAD_DIR);
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { if ($base === false) {
return ["error" => "Invalid folder name."]; return [null, 'root', "Uploads directory not configured correctly."];
}
if ($parent !== "" && !preg_match(REGEX_FOLDER_NAME, $parent)) {
return ["error" => "Invalid parent folder name."];
} }
$baseDir = rtrim(UPLOAD_DIR, '/\\'); if (strtolower($folder) === 'root') {
if ($parent !== "" && strtolower($parent) !== "root") { $dir = $base;
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName;
$relativePath = $parent . "/" . $folderName;
} else { } else {
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $folderName; // validate each segment against REGEX_FOLDER_NAME
$relativePath = $folderName; $parts = array_filter(explode('/', trim($folder, "/\\ ")), fn($p) => $p !== '');
} if (empty($parts)) {
return [null, 'root', "Invalid folder name."];
// 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."];
} }
return ["success" => true]; foreach ($parts as $seg) {
} else { if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
return ["error" => "Failed to create folder."]; 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. * Build metadata file path for a given (relative) folder.
*
* @param string $folder The relative folder path.
* @return string The metadata file path.
*/ */
private static function getMetadataFilePath(string $folder): string private static function getMetadataFilePath(string $folder): string
{ {
@@ -67,134 +70,146 @@ class FolderModel
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json'; 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. * 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 public static function deleteFolder(string $folder): array
{ {
// Prevent deletion of "root".
if (strtolower($folder) === 'root') { if (strtolower($folder) === 'root') {
return ["error" => "Cannot delete root folder."]; return ["error" => "Cannot delete root folder."];
} }
// Validate folder name. [$real, $relative, $err] = self::resolveFolderPath($folder, false);
if (!preg_match(REGEX_FOLDER_NAME, $folder)) { if ($err) return ["error" => $err];
return ["error" => "Invalid folder name."];
}
// Build the full folder path. // Prevent deletion if not empty.
$baseDir = rtrim(UPLOAD_DIR, '/\\'); $items = array_diff(scandir($real), array('.', '..'));
$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('.', '..'));
if (count($items) > 0) { if (count($items) > 0) {
return ["error" => "Folder is not empty."]; return ["error" => "Folder is not empty."];
} }
// Attempt to delete the folder. if (!rmdir($real)) {
if (rmdir($folderPath)) {
// Remove corresponding metadata file.
$metadataFile = self::getMetadataFilePath($folder);
if (file_exists($metadataFile)) {
unlink($metadataFile);
}
return ["success" => true];
} else {
return ["error" => "Failed to delete folder."]; 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. * Renames a folder and updates related metadata files (by renaming their filenames).
*
* @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.
*/ */
public static function renameFolder(string $oldFolder, string $newFolder): array public static function renameFolder(string $oldFolder, string $newFolder): array
{ {
// Sanitize and trim folder names.
$oldFolder = trim($oldFolder, "/\\ "); $oldFolder = trim($oldFolder, "/\\ ");
$newFolder = trim($newFolder, "/\\ "); $newFolder = trim($newFolder, "/\\ ");
// Validate folder names. // Validate names (per-segment)
if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) { foreach ([$oldFolder, $newFolder] as $f) {
return ["error" => "Invalid folder name(s)."]; $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. [$oldReal, $oldRel, $err] = self::resolveFolderPath($oldFolder, false);
$baseDir = rtrim(UPLOAD_DIR, '/\\'); if ($err) return ["error" => $err];
$oldPath = $baseDir . DIRECTORY_SEPARATOR . $oldFolder;
$newPath = $baseDir . DIRECTORY_SEPARATOR . $newFolder;
// Validate that the old folder exists and new folder does not. $base = realpath(UPLOAD_DIR);
if ((realpath($oldPath) === false) || (realpath(dirname($newPath)) === false) || if ($base === false) return ["error" => "Uploads directory not configured correctly."];
strpos(realpath($oldPath), realpath($baseDir)) !== 0 ||
strpos(realpath(dirname($newPath)), realpath($baseDir)) !== 0 $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."]; return ["error" => "Invalid folder path."];
} }
if (!file_exists($oldPath) || !is_dir($oldPath)) {
return ["error" => "Folder to rename does not exist."];
}
if (file_exists($newPath)) { if (file_exists($newPath)) {
return ["error" => "New folder name already exists."]; return ["error" => "New folder name already exists."];
} }
// Attempt to rename the folder. if (!rename($oldReal, $newPath)) {
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 {
return ["error" => "Failed to rename folder."]; 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. * Recursively scans a directory for subfolders (relative paths).
*
* @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).
*/ */
private static function getSubfolders(string $dir, string $relative = ''): array private static function getSubfolders(string $dir, string $relative = ''): array
{ {
$folders = []; $folders = [];
$items = scandir($dir); $items = @scandir($dir) ?: [];
$safeFolderNamePattern = REGEX_FOLDER_NAME;
foreach ($items as $item) { foreach ($items as $item) {
if ($item === '.' || $item === '..') { if ($item === '.' || $item === '..') continue;
continue; if (!preg_match(REGEX_FOLDER_NAME, $item)) continue;
}
if (!preg_match($safeFolderNamePattern, $item)) {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $item; $path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) { if (is_dir($path)) {
$folderPath = ($relative ? $relative . '/' : '') . $item; $folderPath = ($relative ? $relative . '/' : '') . $item;
$folders[] = $folderPath; $folders[] = $folderPath;
$subFolders = self::getSubfolders($path, $folderPath); $folders = array_merge($folders, self::getSubfolders($path, $folderPath));
$folders = array_merge($folders, $subFolders);
} }
} }
return $folders; return $folders;
@@ -202,35 +217,31 @@ class FolderModel
/** /**
* Retrieves the list of folders (including "root") along with file count metadata. * 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 public static function getFolderList(): array
{ {
$baseDir = rtrim(UPLOAD_DIR, '/\\'); $baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) {
return []; // or ["error" => "..."]
}
$folderInfoList = []; $folderInfoList = [];
// Process the "root" folder. // root
$rootMetaFile = self::getMetadataFilePath('root'); $rootMetaFile = self::getMetadataFilePath('root');
$rootFileCount = 0; $rootFileCount = 0;
if (file_exists($rootMetaFile)) { if (file_exists($rootMetaFile)) {
$rootMetadata = json_decode(file_get_contents($rootMetaFile), true); $rootMetadata = json_decode(file_get_contents($rootMetaFile), true);
$rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0; $rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0;
} }
$folderInfoList[] = [ $folderInfoList[] = [
"folder" => "root", "folder" => "root",
"fileCount" => $rootFileCount, "fileCount" => $rootFileCount,
"metadataFile" => basename($rootMetaFile) "metadataFile" => basename($rootMetaFile)
]; ];
// Recursively scan for subfolders. // subfolders
if (is_dir($baseDir)) { $subfolders = is_dir($baseDir) ? self::getSubfolders($baseDir) : [];
$subfolders = self::getSubfolders($baseDir);
} else {
$subfolders = [];
}
// For each subfolder, load metadata to get file counts.
foreach ($subfolders as $folder) { foreach ($subfolders as $folder) {
$metaFile = self::getMetadataFilePath($folder); $metaFile = self::getMetadataFilePath($folder);
$fileCount = 0; $fileCount = 0;
@@ -239,8 +250,8 @@ class FolderModel
$fileCount = is_array($metadata) ? count($metadata) : 0; $fileCount = is_array($metadata) ? count($metadata) : 0;
} }
$folderInfoList[] = [ $folderInfoList[] = [
"folder" => $folder, "folder" => $folder,
"fileCount" => $fileCount, "fileCount" => $fileCount,
"metadataFile" => basename($metaFile) "metadataFile" => basename($metaFile)
]; ];
} }
@@ -250,136 +261,101 @@ class FolderModel
/** /**
* Retrieves the share folder record for a given token. * 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 public static function getShareFolderRecord(string $token): ?array
{ {
$shareFile = META_DIR . "share_folder_links.json"; $shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) { if (!file_exists($shareFile)) return null;
return null;
}
$shareLinks = json_decode(file_get_contents($shareFile), true); $shareLinks = json_decode(file_get_contents($shareFile), true);
if (!is_array($shareLinks) || !isset($shareLinks[$token])) { return (is_array($shareLinks) && isset($shareLinks[$token])) ? $shareLinks[$token] : null;
return null;
}
return $shareLinks[$token];
} }
/** /**
* Retrieves shared folder data based on a share token. * 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 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"; $shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) { if (!file_exists($shareFile)) return ["error" => "Share link not found."];
return ["error" => "Share link not found."];
}
$shareLinks = json_decode(file_get_contents($shareFile), true); $shareLinks = json_decode(file_get_contents($shareFile), true);
if (!is_array($shareLinks) || !isset($shareLinks[$token])) { if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
return ["error" => "Share link not found."]; return ["error" => "Share link not found."];
} }
$record = $shareLinks[$token]; $record = $shareLinks[$token];
// Check expiration.
if (time() > $record['expires']) { if (time() > ($record['expires'] ?? 0)) {
return ["error" => "This share link has expired."]; 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)) { if (!empty($record['password']) && empty($providedPass)) {
return ["needs_password" => true]; return ["needs_password" => true];
} }
if (!empty($record['password']) && !password_verify($providedPass, $record['password'])) { if (!empty($record['password']) && !password_verify($providedPass, $record['password'])) {
return ["error" => "Invalid password."]; return ["error" => "Invalid password."];
} }
// Determine the shared folder.
$folder = trim($record['folder'], "/\\ "); // Resolve shared folder
$baseDir = realpath(UPLOAD_DIR); $folder = trim((string)$record['folder'], "/\\ ");
if ($baseDir === false) { [$realFolderPath, $relative, $err] = self::resolveFolderPath($folder === '' ? 'root' : $folder, false);
return ["error" => "Uploads directory not configured correctly."]; if ($err || !is_dir($realFolderPath)) {
}
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)) {
return ["error" => "Shared folder not found."]; return ["error" => "Shared folder not found."];
} }
// Scan for files (only files).
$allFiles = array_values(array_filter(scandir($realFolderPath), function ($item) use ($realFolderPath) { // List files (safe names only; skip hidden)
return is_file($realFolderPath . DIRECTORY_SEPARATOR . $item); $all = @scandir($realFolderPath) ?: [];
})); $allFiles = [];
sort($allFiles); foreach ($all as $it) {
$totalFiles = count($allFiles); if ($it === '.' || $it === '..') continue;
$totalPages = max(1, ceil($totalFiles / $itemsPerPage)); if ($it[0] === '.') continue;
$currentPage = min($page, $totalPages); if (!preg_match(REGEX_FILE_NAME, $it)) continue;
$startIndex = ($currentPage - 1) * $itemsPerPage; 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); $filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage);
return [ return [
"record" => $record, "record" => $record,
"folder" => $folder, "folder" => $relative,
"realFolderPath" => $realFolderPath, "realFolderPath"=> $realFolderPath,
"files" => $filesOnPage, "files" => $filesOnPage,
"currentPage" => $currentPage, "currentPage" => $currentPage,
"totalPages" => $totalPages "totalPages" => $totalPages
]; ];
} }
/** /**
* Creates a share link for a folder. * 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 public static function createShareFolderLink(string $folder, int $expirationSeconds = 3600, string $password = "", int $allowUpload = 0): array
{ {
// Validate folder // Validate folder (and ensure it exists)
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { [$real, $relative, $err] = self::resolveFolderPath($folder, false);
return ["error" => "Invalid folder name."]; if ($err) return ["error" => $err];
}
// Token // Token
try { try {
$token = bin2hex(random_bytes(16)); $token = bin2hex(random_bytes(16));
} catch (Exception $e) { } catch (\Throwable $e) {
return ["error" => "Could not generate token."]; return ["error" => "Could not generate token."];
} }
// Expiry $expires = time() + max(1, $expirationSeconds);
$expires = time() + $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"; $shareFile = META_DIR . "share_folder_links.json";
$links = file_exists($shareFile) $links = file_exists($shareFile)
? json_decode(file_get_contents($shareFile), true) ?? [] ? (json_decode(file_get_contents($shareFile), true) ?? [])
: []; : [];
// Cleanup // cleanup expired
$now = time(); $now = time();
foreach ($links as $k => $v) { foreach ($links as $k => $v) {
if (!empty($v['expires']) && $v['expires'] < $now) { if (!empty($v['expires']) && $v['expires'] < $now) {
@@ -387,107 +363,78 @@ class FolderModel
} }
} }
// Add new
$links[$token] = [ $links[$token] = [
"folder" => $folder, "folder" => $relative,
"expires" => $expires, "expires" => $expires,
"password" => $hashedPassword, "password" => $hashedPassword,
"allowUpload" => $allowUpload "allowUpload" => $allowUpload ? 1 : 0
]; ];
// Save if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX) === false) {
if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)) === false) {
return ["error" => "Could not save share link."]; return ["error" => "Could not save share link."];
} }
// Build URL // Build URL
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http"; $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname()); || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
$baseUrl = $protocol . '://' . rtrim($host, '/'); $scheme = $https ? 'https' : 'http';
$link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token); $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]; return ["token" => $token, "expires" => $expires, "link" => $link];
} }
/** /**
* Retrieves information for a shared file from a shared folder 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 public static function getSharedFileInfo(string $token, string $file): array
{ {
// Load the share folder record.
$shareFile = META_DIR . "share_folder_links.json"; $shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) { if (!file_exists($shareFile)) return ["error" => "Share link not found."];
return ["error" => "Share link not found."];
}
$shareLinks = json_decode(file_get_contents($shareFile), true); $shareLinks = json_decode(file_get_contents($shareFile), true);
if (!is_array($shareLinks) || !isset($shareLinks[$token])) { if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
return ["error" => "Share link not found."]; return ["error" => "Share link not found."];
} }
$record = $shareLinks[$token]; $record = $shareLinks[$token];
// Check if the link has expired. if (time() > ($record['expires'] ?? 0)) {
if (time() > $record['expires']) {
return ["error" => "This share link has expired."]; return ["error" => "This share link has expired."];
} }
// Determine the shared folder. [$realFolderPath, , $err] = self::resolveFolderPath((string)$record['folder'], false);
$folder = trim($record['folder'], "/\\ "); if ($err || !is_dir($realFolderPath)) {
$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)) {
return ["error" => "Shared folder not found."]; return ["error" => "Shared folder not found."];
} }
// Sanitize the file name to prevent path traversal. $file = basename(trim($file));
if (strpos($file, "/") !== false || strpos($file, "\\") !== false) { if (!preg_match(REGEX_FILE_NAME, $file)) {
return ["error" => "Invalid file name."]; return ["error" => "Invalid file name."];
} }
$file = basename($file);
// Build the full file path. $full = $realFolderPath . DIRECTORY_SEPARATOR . $file;
$filePath = $realFolderPath . DIRECTORY_SEPARATOR . $file; $real = realpath($full);
$realFilePath = realpath($filePath); if ($real === false || strpos($real, $realFolderPath) !== 0 || !is_file($real)) {
if ($realFilePath === false || strpos($realFilePath, $realFolderPath) !== 0 || !is_file($realFilePath)) {
return ["error" => "File not found."]; return ["error" => "File not found."];
} }
$mimeType = mime_content_type($realFilePath); $mime = function_exists('mime_content_type') ? mime_content_type($real) : 'application/octet-stream';
return [ return ["realFilePath" => $real, "mimeType" => $mime];
"realFilePath" => $realFilePath,
"mimeType" => $mimeType
];
} }
/** /**
* Handles uploading a file to a shared folder. * 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 public static function uploadToSharedFolder(string $token, array $fileUpload): array
{ {
// Define maximum file size and allowed extensions. // Max size & allowed extensions (mirror FileModels common types)
$maxSize = 50 * 1024 * 1024; // 50 MB $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"; $shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) { if (!file_exists($shareFile)) {
return ["error" => "Share record not found."]; return ["error" => "Share record not found."];
@@ -498,75 +445,50 @@ class FolderModel
} }
$record = $shareLinks[$token]; $record = $shareLinks[$token];
// Check expiration. if (time() > ($record['expires'] ?? 0)) {
if (time() > $record['expires']) {
return ["error" => "This share link has expired."]; return ["error" => "This share link has expired."];
} }
if (empty($record['allowUpload']) || (int)$record['allowUpload'] !== 1) {
// Check whether uploads are allowed.
if (empty($record['allowUpload']) || $record['allowUpload'] != 1) {
return ["error" => "File uploads are not allowed for this share."]; return ["error" => "File uploads are not allowed for this share."];
} }
// Validate file upload presence. if (($fileUpload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
if ($fileUpload['error'] !== UPLOAD_ERR_OK) { return ["error" => "File upload error. Code: " . (int)$fileUpload['error']];
return ["error" => "File upload error. Code: " . $fileUpload['error']];
} }
if (($fileUpload['size'] ?? 0) > $maxSize) {
if ($fileUpload['size'] > $maxSize) {
return ["error" => "File size exceeds allowed limit."]; return ["error" => "File size exceeds allowed limit."];
} }
$uploadedName = basename($fileUpload['name']); $uploadedName = basename((string)($fileUpload['name'] ?? ''));
$ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExtensions)) { if (!in_array($ext, $allowedExtensions, true)) {
return ["error" => "File type not allowed."]; return ["error" => "File type not allowed."];
} }
// Determine the target folder from the share record. // Resolve target folder
$folderName = trim($record['folder'], "/\\"); [$targetDir, $relative, $err] = self::resolveFolderPath((string)$record['folder'], true);
$targetFolder = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR; if ($err) return ["error" => $err];
if (!empty($folderName) && strtolower($folderName) !== 'root') {
$targetFolder .= $folderName;
}
// Verify target folder exists. // New safe filename
$realTargetFolder = realpath($targetFolder); $safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
$uploadDirReal = realpath(UPLOAD_DIR); $newFilename= uniqid('', true) . "_" . $safeBase;
if ($realTargetFolder === false || strpos($realTargetFolder, $uploadDirReal) !== 0 || !is_dir($realTargetFolder)) { $targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
return ["error" => "Shared folder not found."];
}
// 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)) { if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
return ["error" => "Failed to move the uploaded file."]; return ["error" => "Failed to move the uploaded file."];
} }
// --- Metadata Update --- // Update metadata (uploaded + modified + uploader)
// Determine metadata file. $metadataFile = self::getMetadataFilePath($relative);
$metadataKey = (empty($folderName) || strtolower($folderName) === "root") ? "root" : $folderName; $meta = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName; $now = date(DATE_TIME_FORMAT);
$metadataCollection = []; $meta[$newFilename] = [
if (file_exists($metadataFile)) { "uploaded" => $now,
$data = file_get_contents($metadataFile); "modified" => $now,
$metadataCollection = json_decode($data, true); "uploader" => "Outside Share"
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
]; ];
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]; return ["success" => "File uploaded successfully.", "newFilename" => $newFilename];
} }
@@ -574,9 +496,7 @@ class FolderModel
public static function getAllShareFolderLinks(): array public static function getAllShareFolderLinks(): array
{ {
$shareFile = META_DIR . "share_folder_links.json"; $shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) { if (!file_exists($shareFile)) return [];
return [];
}
$links = json_decode(file_get_contents($shareFile), true); $links = json_decode(file_get_contents($shareFile), true);
return is_array($links) ? $links : []; return is_array($links) ? $links : [];
} }
@@ -584,15 +504,13 @@ class FolderModel
public static function deleteShareFolderLink(string $token): bool public static function deleteShareFolderLink(string $token): bool
{ {
$shareFile = META_DIR . "share_folder_links.json"; $shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) { if (!file_exists($shareFile)) return false;
return false;
}
$links = json_decode(file_get_contents($shareFile), true); $links = json_decode(file_get_contents($shareFile), true);
if (!is_array($links) || !isset($links[$token])) { if (!is_array($links) || !isset($links[$token])) return false;
return false;
}
unset($links[$token]); 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; return true;
} }
} }

View File

@@ -6,9 +6,7 @@ require_once PROJECT_ROOT . '/config/config.php';
class userModel class userModel
{ {
/** /**
* Retrieves all users from the users file. * Retrieve all users (username + role).
*
* @return array Returns an array of users.
*/ */
public static function getAllUsers() 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 $username
* @param string $password The plain-text password. * @param string $password
* @param string $isAdmin "1" if admin; "0" otherwise. * @param string $isAdmin "1" or "0"
* @param bool $setupMode If true, overwrite the users file. * @param bool $setupMode overwrite file if true
* @return array Response containing either an error or a success message.
*/ */
public static function addUser($username, $password, $isAdmin, $setupMode) public static function addUser($username, $password, $isAdmin, $setupMode)
{ {
$usersFile = USERS_DIR . USERS_FILE; $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)) { if (!file_exists($usersFile)) {
file_put_contents($usersFile, ''); @file_put_contents($usersFile, '', LOCK_EX);
} }
// Check if username already exists. // Check duplicates
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($existingUsers as $line) { foreach ($existingUsers as $line) {
$parts = explode(':', trim($line)); $parts = explode(':', trim($line));
if ($username === $parts[0]) { if (isset($parts[0]) && $username === $parts[0]) {
return ["error" => "User already exists"]; return ["error" => "User already exists"];
} }
} }
// Hash the password.
$hashedPassword = password_hash($password, PASSWORD_BCRYPT); $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) { if ($setupMode) {
file_put_contents($usersFile, $newUserLine); if (file_put_contents($usersFile, $newUserLine, LOCK_EX) === false) {
return ["error" => "Failed to write users file"];
}
} else { } 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"]; return ["success" => "User added successfully"];
} }
/** /**
* Removes the specified user from the users file and updates the userPermissions file. * Remove a user and update encrypted userPermissions.json.
*
* @param string $usernameToRemove The username to remove.
* @return array An array with either an error message or a success message.
*/ */
public static function removeUser($usernameToRemove) 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)) { if (!file_exists($usersFile)) {
return ["error" => "Users file not found"]; 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 = []; $newUsers = [];
$userFound = false; $userFound = false;
// Loop through users; skip (remove) the specified user.
foreach ($existingUsers as $line) { foreach ($existingUsers as $line) {
$parts = explode(':', trim($line)); $parts = explode(':', trim($line));
if (count($parts) < 3) { if (count($parts) < 3) {
@@ -98,7 +104,7 @@ class userModel
} }
if ($parts[0] === $usernameToRemove) { if ($parts[0] === $usernameToRemove) {
$userFound = true; $userFound = true;
continue; // Do not add this user to the new array. continue; // skip
} }
$newUsers[] = $line; $newUsers[] = $line;
} }
@@ -107,17 +113,25 @@ class userModel
return ["error" => "User not found"]; return ["error" => "User not found"];
} }
// Write the updated user list back to the file. $newContent = $newUsers ? (implode(PHP_EOL, $newUsers) . PHP_EOL) : '';
file_put_contents($usersFile, 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"; $permissionsFile = USERS_DIR . "userPermissions.json";
if (file_exists($permissionsFile)) { if (file_exists($permissionsFile)) {
$permissionsJson = file_get_contents($permissionsFile); $raw = file_get_contents($permissionsFile);
$permissionsArray = json_decode($permissionsJson, true); $decrypted = decryptData($raw, $encryptionKey);
if (is_array($permissionsArray) && isset($permissionsArray[$usernameToRemove])) { $permissionsArray = $decrypted !== false
unset($permissionsArray[$usernameToRemove]); ? json_decode($decrypted, true)
file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT)); : (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. * Get permissions for current user (or all, if admin).
* 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.
*/ */
public static function getUserPermissions() public static function getUserPermissions()
{ {
@@ -137,28 +147,24 @@ class userModel
$permissionsFile = USERS_DIR . "userPermissions.json"; $permissionsFile = USERS_DIR . "userPermissions.json";
$permissionsArray = []; $permissionsArray = [];
// Load permissions if the file exists.
if (file_exists($permissionsFile)) { if (file_exists($permissionsFile)) {
$content = file_get_contents($permissionsFile); $content = file_get_contents($permissionsFile);
// Attempt to decrypt the content. $decrypted = decryptData($content, $encryptionKey);
$decryptedContent = decryptData($content, $encryptionKey); if ($decrypted === false) {
if ($decryptedContent === false) { // tolerate legacy plaintext
// If decryption fails, assume the content is plain JSON.
$permissionsArray = json_decode($content, true); $permissionsArray = json_decode($content, true);
} else { } else {
$permissionsArray = json_decode($decryptedContent, true); $permissionsArray = json_decode($decrypted, true);
} }
if (!is_array($permissionsArray)) { if (!is_array($permissionsArray)) {
$permissionsArray = []; $permissionsArray = [];
} }
} }
// If the user is an admin, return all permissions. if (!empty($_SESSION['isAdmin'])) {
if (isset($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) {
return $permissionsArray; return $permissionsArray;
} }
// Otherwise, return only the permissions for the currently logged-in user.
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
foreach ($permissionsArray as $storedUsername => $data) { foreach ($permissionsArray as $storedUsername => $data) {
if (strcasecmp($storedUsername, $username) === 0) { if (strcasecmp($storedUsername, $username) === 0) {
@@ -166,129 +172,103 @@ class userModel
} }
} }
// If no permissions are found, return an empty object.
return new stdClass(); return new stdClass();
} }
/** /**
* Updates user permissions in the userPermissions.json file. * Update permissions (encrypted on disk). Skips admins.
*
* @param array $permissions An array of permission updates.
* @return array An associative array with a success or error message.
*/ */
public static function updateUserPermissions($permissions) public static function updateUserPermissions($permissions)
{ {
global $encryptionKey; global $encryptionKey;
$permissionsFile = USERS_DIR . "userPermissions.json"; $permissionsFile = USERS_DIR . "userPermissions.json";
$existingPermissions = []; $existingPermissions = [];
// Load existing permissions if available and decrypt. // Load existing (decrypt if needed)
if (file_exists($permissionsFile)) { if (file_exists($permissionsFile)) {
$encryptedContent = file_get_contents($permissionsFile); $encryptedContent = file_get_contents($permissionsFile);
$json = decryptData($encryptedContent, $encryptionKey); $json = decryptData($encryptedContent, $encryptionKey);
$existingPermissions = json_decode($json, true); if ($json === false) $json = $encryptedContent; // plain JSON fallback
if (!is_array($existingPermissions)) { $existingPermissions = json_decode($json, true) ?: [];
$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 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. * Change password (preserve TOTP + extra fields).
*
* @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.
*/ */
public static function changePassword($username, $oldPassword, $newPassword) 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)) { if (!file_exists($usersFile)) {
return ["error" => "Users file not found"]; 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; $userFound = false;
$newLines = []; $newLines = [];
foreach ($lines as $line) { foreach ($lines as $line) {
$parts = explode(':', trim($line)); $parts = explode(':', trim($line));
// Expect at least 3 parts: username, hashed password, and role.
if (count($parts) < 3) { if (count($parts) < 3) {
$newLines[] = $line; $newLines[] = $line;
continue; continue;
} }
$storedUser = $parts[0]; $storedUser = $parts[0];
$storedHash = $parts[1]; $storedHash = $parts[1];
$storedRole = $parts[2];
// Preserve TOTP secret if it exists.
$totpSecret = (count($parts) >= 4) ? $parts[3] : "";
if ($storedUser === $username) { if ($storedUser === $username) {
$userFound = true; $userFound = true;
// Verify the old password.
if (!password_verify($oldPassword, $storedHash)) { if (!password_verify($oldPassword, $storedHash)) {
return ["error" => "Old password is incorrect."]; return ["error" => "Old password is incorrect."];
} }
// Hash the new password. $parts[1] = password_hash($newPassword, PASSWORD_BCRYPT);
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT); $newLines[] = implode(':', $parts);
// Rebuild the line, preserving TOTP secret if it exists.
if ($totpSecret !== "") {
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret;
} else {
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole;
}
} else { } else {
$newLines[] = $line; $newLines[] = $line;
} }
@@ -298,148 +278,128 @@ class userModel
return ["error" => "User not found."]; return ["error" => "User not found."];
} }
// Save the updated users file. $payload = implode(PHP_EOL, $newLines) . PHP_EOL;
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) { if (file_put_contents($usersFile, $payload, LOCK_EX) === false) {
return ["success" => "Password updated successfully."];
} else {
return ["error" => "Could not update password."]; 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. * Update panel: if TOTP disabled, clear secret.
*
* @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.
*/ */
public static function updateUserPanel($username, $totp_enabled) 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)) { if (!file_exists($usersFile)) {
return ["error" => "Users file not found"]; return ["error" => "Users file not found"];
} }
// If TOTP is disabled, update the file to clear the TOTP secret.
if (!$totp_enabled) { 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 = []; $newLines = [];
foreach ($lines as $line) { foreach ($lines as $line) {
$parts = explode(':', trim($line)); $parts = explode(':', trim($line));
// Leave lines with fewer than three parts unchanged.
if (count($parts) < 3) { if (count($parts) < 3) {
$newLines[] = $line; $newLines[] = $line;
continue; continue;
} }
if ($parts[0] === $username) { if ($parts[0] === $username) {
// If a fourth field (TOTP secret) exists, clear it; otherwise, append an empty field. while (count($parts) < 4) {
if (count($parts) >= 4) {
$parts[3] = "";
} else {
$parts[] = ""; $parts[] = "";
} }
$parts[3] = "";
$newLines[] = implode(':', $parts); $newLines[] = implode(':', $parts);
} else { } else {
$newLines[] = $line; $newLines[] = $line;
} }
} }
$result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX); if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX) === false) {
if ($result === false) {
return ["error" => "Failed to disable TOTP secret"]; return ["error" => "Failed to disable TOTP secret"];
} }
return ["success" => "User panel updated: TOTP disabled"]; return ["success" => "User panel updated: TOTP disabled"];
} }
// If TOTP is enabled, do nothing.
return ["success" => "User panel updated: TOTP remains enabled"]; return ["success" => "User panel updated: TOTP remains enabled"];
} }
/** /**
* Disables the TOTP secret for the specified user. * Clear TOTP secret.
*
* @param string $username The user for whom TOTP should be disabled.
* @return bool True if the secret was cleared; false otherwise.
*/ */
public static function disableTOTPSecret($username) public static function disableTOTPSecret($username)
{ {
global $encryptionKey; // In case it's used in this model context.
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) { if (!file_exists($usersFile)) {
return false; 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; $modified = false;
$newLines = []; $newLines = [];
foreach ($lines as $line) { foreach ($lines as $line) {
$parts = explode(':', trim($line)); $parts = explode(':', trim($line));
// If the line doesn't have at least three parts, leave it unchanged.
if (count($parts) < 3) { if (count($parts) < 3) {
$newLines[] = $line; $newLines[] = $line;
continue; continue;
} }
if ($parts[0] === $username) { if ($parts[0] === $username) {
// If a fourth field exists, clear it; otherwise, append an empty field. while (count($parts) < 4) {
if (count($parts) >= 4) {
$parts[3] = "";
} else {
$parts[] = ""; $parts[] = "";
} }
$parts[3] = "";
$modified = true; $modified = true;
$newLines[] = implode(":", $parts); $newLines[] = implode(":", $parts);
} else { } else {
$newLines[] = $line; $newLines[] = $line;
} }
} }
if ($modified) { 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; return $modified;
} }
/** /**
* Attempts to recover TOTP for a user using the supplied recovery code. * Recover via 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'.
*/ */
public static function recoverTOTP($userId, $recoveryCode) public static function recoverTOTP($userId, $recoveryCode)
{ {
// --- Ratelimit recovery attempts --- // Rate limit storage
$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json'; $attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json';
$attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : []; $attempts = is_file($attemptsFile) ? (json_decode(@file_get_contents($attemptsFile), true) ?: []) : [];
$key = $_SERVER['REMOTE_ADDR'] . '|' . $userId; $key = ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0') . '|' . $userId;
$now = time(); $now = time();
if (isset($attempts[$key])) { if (isset($attempts[$key])) {
// Prune attempts older than 15 minutes. $attempts[$key] = array_values(array_filter($attempts[$key], fn($ts) => $ts > $now - 900));
$attempts[$key] = array_filter($attempts[$key], function ($ts) use ($now) {
return $ts > $now - 900;
});
} }
if (count($attempts[$key] ?? []) >= 5) { if (count($attempts[$key] ?? []) >= 5) {
return ['status' => 'error', 'message' => 'Too many attempts. Try again later.']; 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'; $userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
if (!file_exists($userFile)) { if (!file_exists($userFile)) {
return ['status' => 'error', 'message' => 'User not found']; return ['status' => 'error', 'message' => 'User not found'];
} }
// --- Open and lock file ---
$fp = fopen($userFile, 'c+'); $fp = fopen($userFile, 'c+');
if (!$fp || !flock($fp, LOCK_EX)) { if (!$fp || !flock($fp, LOCK_EX)) {
if ($fp) fclose($fp);
return ['status' => 'error', 'message' => 'Server error']; return ['status' => 'error', 'message' => 'Server error'];
} }
$fileContents = stream_get_contents($fp); $fileContents = stream_get_contents($fp);
$data = json_decode($fileContents, true) ?: []; $data = json_decode($fileContents, true) ?: [];
// --- Check recovery code ---
if (empty($recoveryCode)) { if (empty($recoveryCode)) {
flock($fp, LOCK_UN); flock($fp, LOCK_UN);
fclose($fp); fclose($fp);
@@ -448,19 +408,19 @@ class userModel
$storedHash = $data['totp_recovery_code'] ?? null; $storedHash = $data['totp_recovery_code'] ?? null;
if (!$storedHash || !password_verify($recoveryCode, $storedHash)) { if (!$storedHash || !password_verify($recoveryCode, $storedHash)) {
// Record failed attempt. // record failed attempt
$attempts[$key][] = $now; $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); flock($fp, LOCK_UN);
fclose($fp); fclose($fp);
return ['status' => 'error', 'message' => 'Invalid recovery code']; return ['status' => 'error', 'message' => 'Invalid recovery code'];
} }
// --- Invalidate recovery code --- // Invalidate code
$data['totp_recovery_code'] = null; $data['totp_recovery_code'] = null;
rewind($fp); rewind($fp);
ftruncate($fp, 0); ftruncate($fp, 0);
fwrite($fp, json_encode($data)); fwrite($fp, json_encode($data, JSON_PRETTY_PRINT));
fflush($fp); fflush($fp);
flock($fp, LOCK_UN); flock($fp, LOCK_UN);
fclose($fp); fclose($fp);
@@ -469,10 +429,7 @@ class userModel
} }
/** /**
* Generates a random recovery code. * Generate random recovery code.
*
* @param int $length Length of the recovery code.
* @return string
*/ */
private static function generateRecoveryCode($length = 12) private static function generateRecoveryCode($length = 12)
{ {
@@ -486,45 +443,34 @@ class userModel
} }
/** /**
* Saves a new TOTP recovery code for the specified user. * Save new TOTP recovery code (hash on disk) and return plaintext to caller.
*
* @param string $userId The username of the user.
* @return array An associative array with the status and recovery code (if successful).
*/ */
public static function saveTOTPRecoveryCode($userId) public static function saveTOTPRecoveryCode($userId)
{ {
// Determine the user file path.
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json'; $userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
// Ensure the file exists; if not, create it with default data.
if (!file_exists($userFile)) { if (!file_exists($userFile)) {
$defaultData = []; if (file_put_contents($userFile, json_encode([], JSON_PRETTY_PRINT), LOCK_EX) === false) {
if (file_put_contents($userFile, json_encode($defaultData)) === false) {
return ['status' => 'error', 'message' => 'Server error: could not create user file']; return ['status' => 'error', 'message' => 'Server error: could not create user file'];
} }
} }
// Generate a new recovery code.
$recoveryCode = self::generateRecoveryCode(); $recoveryCode = self::generateRecoveryCode();
$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT); $recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT);
// Open the file, lock it, and update the totp_recovery_code field.
$fp = fopen($userFile, 'c+'); $fp = fopen($userFile, 'c+');
if (!$fp || !flock($fp, LOCK_EX)) { if (!$fp || !flock($fp, LOCK_EX)) {
if ($fp) fclose($fp);
return ['status' => 'error', 'message' => 'Server error: could not lock user file']; return ['status' => 'error', 'message' => 'Server error: could not lock user file'];
} }
// Read and decode the existing JSON.
$contents = stream_get_contents($fp); $contents = stream_get_contents($fp);
$data = json_decode($contents, true) ?: []; $data = json_decode($contents, true) ?: [];
// Update the totp_recovery_code field.
$data['totp_recovery_code'] = $recoveryHash; $data['totp_recovery_code'] = $recoveryHash;
// Write the new data.
rewind($fp); rewind($fp);
ftruncate($fp, 0); ftruncate($fp, 0);
fwrite($fp, json_encode($data)); // Plain JSON in production. fwrite($fp, json_encode($data, JSON_PRETTY_PRINT));
fflush($fp); fflush($fp);
flock($fp, LOCK_UN); flock($fp, LOCK_UN);
fclose($fp); fclose($fp);
@@ -533,11 +479,7 @@ class userModel
} }
/** /**
* Sets up TOTP for the specified user by retrieving or generating a TOTP secret, * Setup TOTP & build QR PNG.
* 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'.
*/ */
public static function setupTOTP($username) public static function setupTOTP($username)
{ {
@@ -548,9 +490,9 @@ class userModel
return ['error' => 'Users file not found']; 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; $totpSecret = null;
foreach ($lines as $line) { foreach ($lines as $line) {
$parts = explode(':', trim($line)); $parts = explode(':', trim($line));
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) { 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( $tfa = new \RobThree\Auth\TwoFactorAuth(
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), // QR code provider new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
'FileRise', // issuer 'FileRise',
6, // number of digits 6,
30, // period (seconds) 30,
\RobThree\Auth\Algorithm::Sha1 // algorithm \RobThree\Auth\Algorithm::Sha1
); );
if (!$totpSecret) { if (!$totpSecret) {
$totpSecret = $tfa->createSecret(); $totpSecret = $tfa->createSecret();
$encryptedSecret = encryptData($totpSecret, $encryptionKey); $encryptedSecret = encryptData($totpSecret, $encryptionKey);
// Update the users line with the new encrypted secret.
$newLines = []; $newLines = [];
foreach ($lines as $line) { foreach ($lines as $line) {
$parts = explode(':', trim($line)); $parts = explode(':', trim($line));
@@ -589,8 +530,7 @@ class userModel
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX); file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
} }
// Determine the OTPAuth URL. // Prefer admin-configured otpauth template if present
// Try to load a global OTPAuth URL template from admin configuration.
$adminConfigFile = USERS_DIR . 'adminConfig.json'; $adminConfigFile = USERS_DIR . 'adminConfig.json';
$globalOtpauthUrl = ""; $globalOtpauthUrl = "";
if (file_exists($adminConfigFile)) { if (file_exists($adminConfigFile)) {
@@ -598,7 +538,7 @@ class userModel
$decryptedContent = decryptData($encryptedContent, $encryptionKey); $decryptedContent = decryptData($encryptedContent, $encryptionKey);
if ($decryptedContent !== false) { if ($decryptedContent !== false) {
$config = json_decode($decryptedContent, true); $config = json_decode($decryptedContent, true);
if (isset($config['globalOtpauthUrl']) && !empty($config['globalOtpauthUrl'])) { if (!empty($config['globalOtpauthUrl'])) {
$globalOtpauthUrl = $config['globalOtpauthUrl']; $globalOtpauthUrl = $config['globalOtpauthUrl'];
} }
} }
@@ -606,14 +546,17 @@ class userModel
if (!empty($globalOtpauthUrl)) { if (!empty($globalOtpauthUrl)) {
$label = "FileRise:" . $username; $label = "FileRise:" . $username;
$otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl); $otpauthUrl = str_replace(
["{label}", "{secret}"],
[urlencode($label), $totpSecret],
$globalOtpauthUrl
);
} else { } else {
$label = urlencode("FileRise:" . $username); $label = urlencode("FileRise:" . $username);
$issuer = urlencode("FileRise"); $issuer = urlencode("FileRise");
$otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}"; $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() $result = \Endroid\QrCode\Builder\Builder::create()
->writer(new \Endroid\QrCode\Writer\PngWriter()) ->writer(new \Endroid\QrCode\Writer\PngWriter())
->data($otpauthUrl) ->data($otpauthUrl)
@@ -626,10 +569,7 @@ class userModel
} }
/** /**
* Retrieves the decrypted TOTP secret for a given user. * Get decrypted TOTP secret.
*
* @param string $username
* @return string|null Returns the TOTP secret if found, or null if not.
*/ */
public static function getTOTPSecret($username) public static function getTOTPSecret($username)
{ {
@@ -638,10 +578,9 @@ class userModel
if (!file_exists($usersFile)) { if (!file_exists($usersFile)) {
return null; 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) { foreach ($lines as $line) {
$parts = explode(':', trim($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])) { if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
return decryptData($parts[3], $encryptionKey); return decryptData($parts[3], $encryptionKey);
} }
@@ -650,10 +589,7 @@ class userModel
} }
/** /**
* Helper to get a user's role from users.txt. * Get role ('1' admin, '0' user) or null.
*
* @param string $username
* @return string|null
*/ */
public static function getUserRole($username) public static function getUserRole($username)
{ {
@@ -670,27 +606,30 @@ class userModel
return null; return null;
} }
/**
* Get a single users info (admin flag, TOTP status, profile picture).
*/
public static function getUser(string $username): array public static function getUser(string $username): array
{ {
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (! file_exists($usersFile)) { if (!file_exists($usersFile)) {
return []; return [];
} }
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
// split *all* the fields
$parts = explode(':', $line); $parts = explode(':', $line);
if ($parts[0] !== $username) { if ($parts[0] !== $username) {
continue; continue;
} }
// determine admin & totp
$isAdmin = (isset($parts[2]) && $parts[2] === '1'); $isAdmin = (isset($parts[2]) && $parts[2] === '1');
$totpEnabled = !empty($parts[3]); $totpEnabled = !empty($parts[3]);
// profile_picture is the 5th field if present
$pic = isset($parts[4]) ? $parts[4] : ''; $pic = isset($parts[4]) ? $parts[4] : '';
// Normalize to a leading slash (UI expects /uploads/…)
if ($pic !== '' && $pic[0] !== '/') {
$pic = '/' . $pic;
}
return [ return [
'username' => $parts[0], 'username' => $parts[0],
'isAdmin' => $isAdmin, 'isAdmin' => $isAdmin,
@@ -699,48 +638,43 @@ class userModel
]; ];
} }
return []; // user not found return [];
} }
/** /**
* Persistently set the profile picture URL for a given user, * Persist profile picture URL as 5th field (keeps TOTP secret intact).
* storing it in the 5th field so we leave the 4th (TOTP secret) untouched.
* *
* users.txt format: * users.txt: username:hash:isAdmin:totp_secret:profile_picture
* 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'=>'…']
*/ */
public static function setProfilePicture(string $username, string $url): array public static function setProfilePicture(string $username, string $url): array
{ {
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
if (! file_exists($usersFile)) { if (!file_exists($usersFile)) {
return ['success' => false, 'error' => 'Users file not found']; 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 = []; $out = [];
$found = false; $found = false;
foreach ($lines as $line) { foreach ($lines as $line) {
if ($line === '') { $out[] = $line; continue; }
$parts = explode(':', $line); $parts = explode(':', $line);
if ($parts[0] === $username) { if ($parts[0] === $username) {
$found = true; $found = true;
// Ensure we have at least 5 fields
while (count($parts) < 5) { while (count($parts) < 5) {
$parts[] = ''; $parts[] = '';
} }
// Write profile_picture into the 5th field (index 4) $parts[4] = $url;
$parts[4] = ltrim($url, '/'); // or $url if leading slash is desired
// Re-assemble (this preserves parts[3] completely)
$line = implode(':', $parts); $line = implode(':', $parts);
} }
$out[] = $line; $out[] = $line;
} }
if (! $found) { if (!$found) {
return ['success' => false, 'error' => 'User not found']; return ['success' => false, 'error' => 'User not found'];
} }