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
## 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)
chore: set up CI, add compose, tighten ignores, refresh README
@@ -195,7 +292,7 @@ No behavior change unless SCAN_ON_START=true.
- **Folder strip in file list**
- `loadFileList` now fetches sub-folders in parallel from `/api/folder/getFolderList.php`.
- Filters to only *direct* children of the current folder, hiding `profile_pics` and `trash`.
- Filters to only direct children of the current folder, hiding `profile_pics` and `trash`.
- Injects a new `.folder-strip-container` just below the Files In above (summary + slider).
- Clicking a folder in the strip updates:
- the breadcrumb (via `updateBreadcrumbTitle`)
@@ -243,7 +340,7 @@ No behavior change unless SCAN_ON_START=true.
- Moved previously standalone header buttons into the dropdown menu:
- **User Panel** opens the modal
- **Admin Panel** only shown when `data.isAdmin` *and* on `demo.filerise.net`
- **Admin Panel** only shown when `data.isAdmin` and on `demo.filerise.net`
- **API Docs** calls `openApiModal()`
- **Logout** calls `triggerLogout()`
- Each menu item now has a matching Material icon (e.g. `person`, `admin_panel_settings`, `description`, `logout`).
@@ -364,7 +461,7 @@ No behavior change unless SCAN_ON_START=true.
- Removed the static `AUTH_HEADER` fallback; instead read the adminConfig.json at the end of the file and:
- Overwrote `AUTH_BYPASS` with the `loginOptions.authBypass` setting from disk.
- Defined `AUTH_HEADER` (normalized, e.g. `"X_REMOTE_USER"`) based on `loginOptions.authHeaderName`.
- Inserted a **proxy-only auto-login** block *before* the usual session/auth checks:
- Inserted a **proxy-only auto-login** block before the usual session/auth checks:
If `AUTH_BYPASS` is true and the trusted header (`$_SERVER['HTTP_' . AUTH_HEADER]`) is present, bump the session, mark the user authenticated/admin, load their permissions, and skip straight to JSON output.
- Relax filename validation regex to allow broader Unicode and special chars
@@ -406,7 +503,7 @@ No behavior change unless SCAN_ON_START=true.
- In the “not authenticated” branch, only shows the login form if `authBypass` is false.
- No other core fetch/token logic changed; all existing flows remain intact.
### Security
### Security old
- **Admin API**: `getConfig.php` now returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
@@ -435,7 +532,7 @@ No behavior change unless SCAN_ON_START=true.
- **Added** `addUserModal`, `removeUserModal` & `renameFileModal` modals to `style="display:none;"`
### `main.js`
**`main.js`**
- **Extracted** `initializeApp()` helper to centralize post-auth startup (tag search, file list, drag-and-drop, folder tree, upload, trash/restore, admin config).
- **Updated** DOMContentLoaded `checkAuthentication()` flow to call `initializeApp()` when already authenticated.

View File

@@ -2,7 +2,12 @@
## Supported Versions
FileRise is actively maintained. Only supported versions will receive security updates. For details on which versions are currently supported, please see the [Release Notes](https://github.com/error311/FileRise/releases).
We provide security fixes for the latest minor release line.
| Version | Supported |
|------------|-----------|
| v1.4.x | ✅ |
| < v1.4.0 | |
## Reporting a Vulnerability

View File

@@ -36,6 +36,13 @@ define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
date_default_timezone_set(TIMEZONE);
if (!defined('DEFAULT_BYPASS_OWNERSHIP')) define('DEFAULT_BYPASS_OWNERSHIP', false);
if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true);
if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
// Encryption helpers
function encryptData($data, $encryptionKey)
{

View File

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

View File

@@ -55,6 +55,18 @@ window.advancedSearchEnabled = false;
* --- Helper Functions ---
*/
// Safely parse JSON; if server returned HTML/text, throw it as a readable error.
async function safeJson(res) {
const text = await res.text();
try {
return JSON.parse(text);
} catch {
// Common cases: PHP notice/HTML, "Access forbidden.", etc.
const msg = (text || '').toString().trim();
throw new Error(msg || `Unexpected ${res.status} ${res.statusText} from ${res.url || 'request'}`);
}
}
/**
* Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes.
*/
@@ -219,8 +231,14 @@ export async function loadFileList(folderParam) {
try {
// Kick off both in parallel, but we'll render as soon as FILES are ready
const filesPromise = fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`);
const foldersPromise = fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`);
const filesPromise = fetch(
`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`,
{ credentials: 'include' }
);
const foldersPromise = fetch(
`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`,
{ credentials: 'include' }
);
// ----- FILES FIRST -----
const filesRes = await filesPromise;
@@ -230,7 +248,10 @@ export async function loadFileList(folderParam) {
throw new Error("Unauthorized");
}
const data = await filesRes.json();
const data = await safeJson(filesRes);
if (data.error) {
throw new Error(typeof data.error === 'string' ? data.error : 'Server returned an error.');
}
// If another loadFileList ran after this one, bail before touching the DOM
if (reqId !== __fileListReqSeq) return [];
@@ -403,7 +424,7 @@ export async function loadFileList(folderParam) {
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
try {
const foldersRes = await foldersPromise;
const folderRaw = await foldersRes.json();
const folderRaw = await safeJson(foldersRes).catch(() => []); // don't block file render on folder strip issues
if (reqId !== __fileListReqSeq) return data.files;
// --- build ONLY the *direct* children of current folder ---

View File

@@ -2,8 +2,6 @@ import { sendRequest } from './networkUtils.js';
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
import { initUpload } from './upload.js';
import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js';
const _originalFetch = window.fetch;
window.fetch = fetchWithCsrf;
import { loadFolderTree } from './folderManager.js';
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
@@ -14,14 +12,60 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload }
import { editFile, saveFile } from './fileEditor.js';
import { t, applyTranslations, setLocale } from './i18n.js';
/* =========================
CSRF HOTFIX UTILITIES
========================= */
const _nativeFetch = window.fetch; // keep the real fetch
function setCsrfToken(token) {
if (!token) return;
window.csrfToken = token;
localStorage.setItem('csrf', token);
// meta tag for easy access in other places
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'csrf-token';
document.head.appendChild(meta);
}
meta.content = token;
}
function getCsrfToken() {
return window.csrfToken || localStorage.getItem('csrf') || '';
}
// Seed CSRF from storage ASAP (before any requests)
setCsrfToken(getCsrfToken());
// Wrap the existing fetchWithCsrf so we also capture rotated tokens from headers.
async function fetchWithCsrfAndRefresh(input, init = {}) {
const res = await fetchWithCsrf(input, init);
try {
const rotated = res.headers?.get('X-CSRF-Token');
if (rotated) setCsrfToken(rotated);
} catch { /* ignore */ }
return res;
}
// Replace global fetch with the wrapped version so *all* callers benefit.
window.fetch = fetchWithCsrfAndRefresh;
/* =========================
APP INIT
========================= */
export function initializeApp() {
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
window.currentFolder = "root";
initTagSearch();
loadFileList(window.currentFolder);
const stored = localStorage.getItem('showFoldersInList');
window.showFoldersInList = stored === null ? true : stored === 'true';
initTagSearch();
loadFileList(window.currentFolder);
const fileListArea = document.getElementById('fileListContainer');
const uploadArea = document.getElementById('uploadDropArea');
if (fileListArea && uploadArea) {
@@ -35,7 +79,6 @@ export function initializeApp() {
fileListArea.addEventListener('drop', e => {
e.preventDefault();
fileListArea.classList.remove('drop-hover');
// re-dispatch the same drop into the real upload card
uploadArea.dispatchEvent(new DragEvent('drop', {
dataTransfer: e.dataTransfer,
bubbles: true,
@@ -63,27 +106,36 @@ export function initializeApp() {
}
}
/**
* Bootstrap/refresh CSRF from the server.
* Uses the *native* fetch to avoid any wrapper loops and to work even if we don't
* yet have a token. Also accepts a rotated token from the response header.
*/
export function loadCsrfToken() {
return fetchWithCsrf('/api/auth/token.php', { method: 'GET' })
.then(res => {
if (!res.ok) throw new Error(`Token fetch failed with status ${res.status}`);
return res.json();
})
.then(({ csrf_token, share_url }) => {
window.csrfToken = csrf_token;
return _nativeFetch('/api/auth/token.php', { method: 'GET', credentials: 'include' })
.then(async res => {
// header-based rotation
const hdr = res.headers.get('X-CSRF-Token');
if (hdr) setCsrfToken(hdr);
// update CSRF meta
let meta = document.querySelector('meta[name="csrf-token"]') ||
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'csrf-token' });
meta.content = csrf_token;
// body (if provided)
let body = {};
try { body = await res.json(); } catch { /* token endpoint may return empty */ }
// force share_url to match wherever we're browsing
const token = body.csrf_token || getCsrfToken();
setCsrfToken(token);
// share-url meta should reflect the actual origin
const actualShare = window.location.origin;
let shareMeta = document.querySelector('meta[name="share-url"]') ||
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'share-url' });
let shareMeta = document.querySelector('meta[name="share-url"]');
if (!shareMeta) {
shareMeta = document.createElement('meta');
shareMeta.name = 'share-url';
document.head.appendChild(shareMeta);
}
shareMeta.content = actualShare;
return { csrf_token, share_url: actualShare };
return { csrf_token: token, share_url: actualShare };
});
}
@@ -95,16 +147,15 @@ if (params.get('logout') === '1') {
}
export function triggerLogout() {
fetch("/api/auth/logout.php", {
_nativeFetch("/api/auth/logout.php", {
method: "POST",
credentials: "include",
headers: { "X-CSRF-Token": window.csrfToken }
headers: { "X-CSRF-Token": getCsrfToken() }
})
.then(() => window.location.reload(true))
.catch(() => { });
}
// Expose functions for inline handlers.
window.sendRequest = sendRequest;
window.toggleVisibility = toggleVisibility;
@@ -119,105 +170,79 @@ window.openDownloadModal = openDownloadModal;
window.currentFolder = "root";
document.addEventListener("DOMContentLoaded", function () {
loadAdminConfigFunc();
loadAdminConfigFunc(); // Then fetch the latest config and update.
// Retrieve the saved language from localStorage; default to "en"
// i18n
const savedLanguage = localStorage.getItem("language") || "en";
// Set the locale based on the saved language
setLocale(savedLanguage);
// Apply the translations to update the UI
applyTranslations();
// First, load the CSRF token (with retry).
loadCsrfToken().then(() => {
// Once CSRF token is loaded, initialize authentication.
initAuth();
// Continue with initializations that rely on a valid CSRF token:
checkAuthentication().then(authenticated => {
if (authenticated) {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.remove();
initializeApp();
}
});
// 1) Get/refresh CSRF first
loadCsrfToken()
.then(() => {
// 2) Auth boot
initAuth();
// Other DOM initialization that can happen after CSRF is ready.
const newPasswordInput = document.getElementById("newPassword");
if (newPasswordInput) {
newPasswordInput.addEventListener("input", function () {
console.log("newPassword input event:", this.value);
// 3) If authenticated, start app
checkAuthentication().then(authenticated => {
if (authenticated) {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.remove();
initializeApp();
}
});
} else {
console.error("newPassword input not found!");
}
// --- Dark Mode Persistence ---
const darkModeToggle = document.getElementById("darkModeToggle");
const darkModeIcon = document.getElementById("darkModeIcon");
// --- Dark Mode Persistence ---
const darkModeToggle = document.getElementById("darkModeToggle");
const darkModeIcon = document.getElementById("darkModeIcon");
if (darkModeToggle && darkModeIcon) {
// 1) Load stored preference (or null)
let stored = localStorage.getItem("darkMode");
const hasStored = stored !== null;
if (darkModeToggle && darkModeIcon) {
let stored = localStorage.getItem("darkMode");
const hasStored = stored !== null;
// 2) Determine initial mode
const isDark = hasStored
? (stored === "true")
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
const isDark = hasStored
? (stored === "true")
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.body.classList.toggle("dark-mode", isDark);
darkModeToggle.classList.toggle("active", isDark);
document.body.classList.toggle("dark-mode", isDark);
darkModeToggle.classList.toggle("active", isDark);
// 3) Helper to update icon & aria-label
function updateIcon() {
const dark = document.body.classList.contains("dark-mode");
darkModeIcon.textContent = dark ? "light_mode" : "dark_mode";
darkModeToggle.setAttribute(
"aria-label",
dark ? t("light_mode") : t("dark_mode")
);
darkModeToggle.setAttribute(
"title",
dark
? t("switch_to_light_mode")
: t("switch_to_dark_mode")
);
}
updateIcon();
// 4) Click handler: always override and store preference
darkModeToggle.addEventListener("click", () => {
const nowDark = document.body.classList.toggle("dark-mode");
localStorage.setItem("darkMode", nowDark ? "true" : "false");
function updateIcon() {
const dark = document.body.classList.contains("dark-mode");
darkModeIcon.textContent = dark ? "light_mode" : "dark_mode";
darkModeToggle.setAttribute("aria-label", dark ? t("light_mode") : t("dark_mode"));
darkModeToggle.setAttribute("title", dark ? t("switch_to_light_mode") : t("switch_to_dark_mode"));
}
updateIcon();
});
// 5) OSlevel change: only if no stored pref at load
if (!hasStored && window.matchMedia) {
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", e => {
darkModeToggle.addEventListener("click", () => {
const nowDark = document.body.classList.toggle("dark-mode");
localStorage.setItem("darkMode", nowDark ? "true" : "false");
updateIcon();
});
if (!hasStored && window.matchMedia) {
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => {
document.body.classList.toggle("dark-mode", e.matches);
updateIcon();
});
}
}
}
// --- End Dark Mode Persistence ---
// --- End Dark Mode Persistence ---
const message = sessionStorage.getItem("welcomeMessage");
if (message) {
showToast(message);
sessionStorage.removeItem("welcomeMessage");
}
}).catch(error => {
console.error("Initialization halted due to CSRF token load failure.", error);
});
const message = sessionStorage.getItem("welcomeMessage");
if (message) {
showToast(message);
sessionStorage.removeItem("welcomeMessage");
}
})
.catch(error => {
console.error("Initialization halted due to CSRF token load failure.", error);
});
// --- Auto-scroll During Drag ---
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling
const SCROLL_SPEED = 20; // pixels to scroll per event
const SCROLL_THRESHOLD = 50;
const SCROLL_SPEED = 20;
document.addEventListener("dragover", function (e) {
if (e.clientY < SCROLL_THRESHOLD) {
window.scrollBy(0, -SCROLL_SPEED);

View File

@@ -53,6 +53,17 @@ class AdminController
public function getConfig(): void
{
header('Content-Type: application/json');
// Require authenticated admin to read config (prevents information disclosure)
if (
empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
empty($_SESSION['isAdmin'])
) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized access.']);
exit;
}
$config = AdminModel::getConfig();
if (isset($config['error'])) {
http_response_code(500);
@@ -62,14 +73,14 @@ class AdminController
// Build a safe subset for the front-end
$safe = [
'header_title' => $config['header_title'],
'loginOptions' => $config['loginOptions'],
'globalOtpauthUrl' => $config['globalOtpauthUrl'],
'enableWebDAV' => $config['enableWebDAV'],
'sharedMaxUploadSize' => $config['sharedMaxUploadSize'],
'header_title' => $config['header_title'] ?? '',
'loginOptions' => $config['loginOptions'] ?? [],
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
'enableWebDAV' => $config['enableWebDAV'] ?? false,
'sharedMaxUploadSize' => $config['sharedMaxUploadSize'] ?? 0,
'oidc' => [
'providerUrl' => $config['oidc']['providerUrl'],
'redirectUri' => $config['oidc']['redirectUri'],
'providerUrl' => $config['oidc']['providerUrl'] ?? '',
'redirectUri' => $config['oidc']['redirectUri'] ?? '',
// clientSecret and clientId never exposed here
],
];
@@ -137,106 +148,186 @@ class AdminController
* @return void Outputs a JSON response indicating success or failure.
*/
public function updateConfig(): void
{
header('Content-Type: application/json');
{
header('Content-Type: application/json');
// —– auth & CSRF checks —–
if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized access.']);
exit;
}
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = trim($headersArr['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token.']);
exit;
}
// —– fetch payload —–
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input.']);
exit;
}
// —– load existing on-disk config —–
$existing = AdminModel::getConfig();
// —– start merge with existing as base —–
$merged = $existing;
// header_title
if (array_key_exists('header_title', $data)) {
$merged['header_title'] = trim($data['header_title']);
}
// loginOptions: inherit existing then override if provided
$merged['loginOptions'] = $existing['loginOptions'] ?? [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableOIDCLogin'=> true,
'authBypass' => false,
'authHeaderName' => 'X-Remote-User'
];
foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) {
if (isset($data['loginOptions'][$flag])) {
$merged['loginOptions'][$flag] = filter_var(
$data['loginOptions'][$flag],
FILTER_VALIDATE_BOOLEAN
);
// —– auth & CSRF checks —–
if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized access.']);
exit;
}
}
if (isset($data['loginOptions']['authHeaderName'])) {
$hdr = trim($data['loginOptions']['authHeaderName']);
if ($hdr !== '') {
$merged['loginOptions']['authHeaderName'] = $hdr;
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = trim($headersArr['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token.']);
exit;
}
}
// globalOtpauthUrl
if (array_key_exists('globalOtpauthUrl', $data)) {
$merged['globalOtpauthUrl'] = trim($data['globalOtpauthUrl']);
}
// —– fetch payload —–
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input.']);
exit;
}
// enableWebDAV
if (array_key_exists('enableWebDAV', $data)) {
$merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
}
// —– load existing on-disk config —–
$existing = AdminModel::getConfig();
if (isset($existing['error'])) {
http_response_code(500);
echo json_encode(['error' => $existing['error']]);
exit;
}
// sharedMaxUploadSize
if (array_key_exists('sharedMaxUploadSize', $data)) {
$sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
if ($sms !== false) {
// —– start merge with existing as base —–
// Ensure minimal structure if the file was partially missing.
$merged = $existing + [
'header_title' => '',
'loginOptions' => [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableOIDCLogin' => true,
'authBypass' => false,
'authHeaderName' => 'X-Remote-User'
],
'globalOtpauthUrl' => '',
'enableWebDAV' => false,
'sharedMaxUploadSize' => 0,
'oidc' => [
'providerUrl' => '',
'clientId' => '',
'clientSecret'=> '',
'redirectUri' => ''
],
];
// header_title (cap length and strip control chars)
if (array_key_exists('header_title', $data)) {
$title = trim((string)$data['header_title']);
$title = preg_replace('/[\x00-\x1F\x7F]/', '', $title);
if (mb_strlen($title) > 100) { // hard cap
$title = mb_substr($title, 0, 100);
}
$merged['header_title'] = $title;
}
// loginOptions: inherit existing then override if provided
foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) {
if (isset($data['loginOptions'][$flag])) {
$merged['loginOptions'][$flag] = filter_var(
$data['loginOptions'][$flag],
FILTER_VALIDATE_BOOLEAN
);
}
}
if (isset($data['loginOptions']['authHeaderName'])) {
$hdr = trim((string)$data['loginOptions']['authHeaderName']);
// very restrictive header-name pattern: letters, numbers, dashes
if ($hdr !== '' && preg_match('/^[A-Za-z0-9\-]+$/', $hdr)) {
$merged['loginOptions']['authHeaderName'] = $hdr;
} else {
http_response_code(400);
echo json_encode(['error' => 'Invalid authHeaderName.']);
exit;
}
}
// globalOtpauthUrl
if (array_key_exists('globalOtpauthUrl', $data)) {
$merged['globalOtpauthUrl'] = trim((string)$data['globalOtpauthUrl']);
}
// enableWebDAV
if (array_key_exists('enableWebDAV', $data)) {
$merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
}
// sharedMaxUploadSize
if (array_key_exists('sharedMaxUploadSize', $data)) {
$sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
if ($sms === false || $sms < 0) {
http_response_code(400);
echo json_encode(['error' => 'sharedMaxUploadSize must be a non-negative integer (bytes).']);
exit;
}
// Clamp to PHP limits to avoid confusing UX
$maxPost = self::iniToBytes(ini_get('post_max_size'));
$maxFile = self::iniToBytes(ini_get('upload_max_filesize'));
$phpCap = min($maxPost ?: PHP_INT_MAX, $maxFile ?: PHP_INT_MAX);
if ($phpCap !== PHP_INT_MAX && $sms > $phpCap) {
$sms = $phpCap;
}
$merged['sharedMaxUploadSize'] = $sms;
}
}
// oidc: only overwrite non-empty inputs
$merged['oidc'] = $existing['oidc'] ?? [
'providerUrl'=>'','clientId'=>'','clientSecret'=>'','redirectUri'=>''
];
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) {
if (!empty($data['oidc'][$f])) {
$val = trim($data['oidc'][$f]);
if ($f === 'providerUrl' || $f === 'redirectUri') {
$val = filter_var($val, FILTER_SANITIZE_URL);
// oidc: only overwrite non-empty inputs; validate when enabling OIDC
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) {
if (!empty($data['oidc'][$f])) {
$val = trim((string)$data['oidc'][$f]);
if ($f === 'providerUrl' || $f === 'redirectUri') {
$val = filter_var($val, FILTER_SANITIZE_URL);
}
$merged['oidc'][$f] = $val;
}
$merged['oidc'][$f] = $val;
}
// If OIDC login is enabled, ensure required fields are present and sane
$oidcEnabled = !empty($merged['loginOptions']['disableOIDCLogin']) ? false : true;
if ($oidcEnabled) {
$prov = $merged['oidc']['providerUrl'] ?? '';
$rid = $merged['oidc']['redirectUri'] ?? '';
$cid = $merged['oidc']['clientId'] ?? '';
// clientSecret may be empty for some PKCE-only flows, but commonly needed for code flow.
if ($prov === '' || $rid === '' || $cid === '') {
http_response_code(400);
echo json_encode(['error' => 'OIDC is enabled but providerUrl, redirectUri, and clientId are required.']);
exit;
}
// Require https except for localhost development
$httpsOk = function(string $url): bool {
if ($url === '') return false;
$parts = parse_url($url);
if (!$parts || empty($parts['scheme'])) return false;
if ($parts['scheme'] === 'https') return true;
if ($parts['scheme'] === 'http' && (isset($parts['host']) && ($parts['host'] === 'localhost' || $parts['host'] === '127.0.0.1'))) {
return true;
}
return false;
};
if (!$httpsOk($prov) || !$httpsOk($rid)) {
http_response_code(400);
echo json_encode(['error' => 'providerUrl and redirectUri must be https (or http on localhost)']);
exit;
}
}
// —– persist merged config —–
$result = AdminModel::updateConfig($merged);
if (isset($result['error'])) {
http_response_code(500);
}
echo json_encode($result);
exit;
}
// —– persist merged config —–
$result = AdminModel::updateConfig($merged);
if (isset($result['error'])) {
http_response_code(500);
/** Convert php.ini shorthand like "128M" to bytes */
private static function iniToBytes($val)
{
if ($val === false || $val === null || $val === '') return 0;
$val = trim((string)$val);
$last = strtolower($val[strlen($val)-1]);
$num = (int)$val;
switch ($last) {
case 'g': $num *= 1024;
case 'm': $num *= 1024;
case 'k': $num *= 1024;
}
return $num;
}
echo json_encode($result);
exit;
}
}
?>

View File

@@ -3,9 +3,163 @@
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/models/FileModel.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
class FileController
{
/* =========================
* Permission helpers (fail-closed)
* ========================= */
private function isAdmin(array $perms): bool {
// explicit flags in permissions blob
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
// session-based flags commonly set at login
if (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) return true;
// sometimes apps store role in session
$role = $_SESSION['role'] ?? null;
if ($role === 'admin' || $role === '1' || $role === 1) return true;
// definitive fallback: read users.txt role ("1" means admin)
$u = $_SESSION['username'] ?? '';
if ($u) {
$roleStr = userModel::getUserRole($u);
if ($roleStr === '1') return true;
}
return false;
}
private function isFolderOnly(array $perms): bool {
return !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
}
private function getMetadataPath(string $folder): string {
$folder = trim($folder);
if ($folder === '' || strtolower($folder) === 'root') {
return META_DIR . 'root_metadata.json';
}
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
}
private function loadFolderMetadata(string $folder): array {
$meta = $this->getMetadataPath($folder);
if (file_exists($meta)) {
$data = json_decode(file_get_contents($meta), true);
if (is_array($data)) return $data;
}
return [];
}
// Always return an array for user permissions.
private function loadPerms(string $username): array
{
try {
if (function_exists('loadUserPermissions')) {
$p = loadUserPermissions($username);
return is_array($p) ? $p : [];
}
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
$all = userModel::getUserPermissions();
if (is_array($all)) {
if (isset($all[$username])) return (array)$all[$username];
$lk = strtolower($username);
if (isset($all[$lk])) return (array)$all[$lk];
}
}
} catch (\Throwable $e) { /* ignore */ }
return [];
}
/** Enforce that (a) folder-only users act only in their subtree, and
* (b) non-admins own all files in the provided list (metadata.uploader === $username).
* Returns an error string on violation, or null if ok. */
private function enforceScopeAndOwnership(string $folder, array $files, string $username, array $userPermissions): ?string {
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
// Folder-only users must stay in "<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(
* path="/api/file/copyFiles.php",
@@ -73,8 +227,8 @@ class FileController
// Check user permissions (assuming loadUserPermissions() is available).
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if (!empty($userPermissions['readOnly'])) {
$userPermissions = $this->loadPerms($username);
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to copy files."]);
exit;
}
@@ -106,6 +260,12 @@ class FileController
exit;
}
// Scope + ownership on source; scope on destination
$violation = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
if ($violation) { http_response_code(403); echo json_encode(["error"=>$violation]); return; }
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions);
if ($dv) { http_response_code(403); echo json_encode(["error"=>$dv]); return; }
// Delegate to the model.
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
echo json_encode($result);
@@ -177,7 +337,7 @@ class FileController
// Load user's permissions.
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
$userPermissions = $this->loadPerms($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to delete files."]);
exit;
@@ -199,6 +359,10 @@ class FileController
}
$folder = trim($folder, "/\\ ");
// Scope + ownership
$violation = $this->enforceScopeAndOwnership($folder, $data['files'], $username, $userPermissions);
if ($violation) { http_response_code(403); echo json_encode(["error"=>$violation]); return; }
// Delegate to the FileModel.
$result = FileModel::deleteFiles($folder, $data['files']);
echo json_encode($result);
@@ -271,8 +435,8 @@ class FileController
// Verify that the user is not read-only.
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if (!empty($userPermissions['readOnly'])) {
$userPermissions = $this->loadPerms($username);
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to move files."]);
exit;
}
@@ -303,6 +467,12 @@ class FileController
exit;
}
// Scope + ownership on source; scope on destination
$violation = $this->enforceScopeAndOwnership($sourceFolder, $data['files'], $username, $userPermissions);
if ($violation) { http_response_code(403); echo json_encode(["error"=>$violation]); return; }
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions);
if ($dv) { http_response_code(403); echo json_encode(["error"=>$dv]); return; }
// Delegate to the model.
$result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['files']);
echo json_encode($result);
@@ -351,64 +521,63 @@ class FileController
* @return void Outputs a JSON response.
*/
public function renameFile()
{
header('Content-Type: application/json');
header("Cache-Control: no-cache, no-store, must-revalidate");
header("Pragma: no-cache");
header("Expires: 0");
{
$this->_jsonStart();
try {
if (!$this->_checkCsrf()) return;
if (!$this->_requireAuth()) return;
// --- CSRF Protection ---
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Verify user permissions.
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to rename files."]);
exit;
$userPermissions = $this->loadPerms($username);
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
$this->_jsonOut(["error" => "Read-only users are not allowed to rename files."], 403);
return;
}
// Get JSON input.
$data = json_decode(file_get_contents("php://input"), true);
if (!$data || !isset($data['folder']) || !isset($data['oldName']) || !isset($data['newName'])) {
http_response_code(400);
echo json_encode(["error" => "Invalid input"]);
exit;
$data = $this->_readJsonBody();
if (!$data || !isset($data['folder'], $data['oldName'], $data['newName'])) {
$this->_jsonOut(["error" => "Invalid input"], 400);
return;
}
$folder = trim($data['folder']) ?: 'root';
// Validate folder: allow letters, numbers, underscores, dashes, spaces, and forward slashes.
$folder = trim((string)$data['folder']) ?: 'root';
$oldName = basename(trim((string)$data['oldName']));
$newName = basename(trim((string)$data['newName']));
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
echo json_encode(["error" => "Invalid folder name"]);
exit;
$this->_jsonOut(["error" => "Invalid folder name"], 400);
return;
}
if ($oldName === '' || !preg_match(REGEX_FILE_NAME, $oldName)) {
$this->_jsonOut(["error" => "Invalid old file name."], 400);
return;
}
if ($newName === '' || !preg_match(REGEX_FILE_NAME, $newName)) {
$this->_jsonOut(["error" => "Invalid new file name."], 400);
return;
}
$oldName = basename(trim($data['oldName']));
$newName = basename(trim($data['newName']));
// Non-admin must own the original
$violation = $this->enforceScopeAndOwnership($folder, [$oldName], $username, $userPermissions);
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
// Validate file names.
if (!preg_match(REGEX_FILE_NAME, $oldName) || !preg_match(REGEX_FILE_NAME, $newName)) {
echo json_encode(["error" => "Invalid file name."]);
exit;
}
// Delegate the renaming operation to the model.
$result = FileModel::renameFile($folder, $oldName, $newName);
echo json_encode($result);
if (!is_array($result)) {
throw new RuntimeException('FileModel::renameFile returned non-array');
}
if (isset($result['error'])) {
$this->_jsonOut($result, 400);
return;
}
$this->_jsonOut($result);
} catch (Throwable $e) {
error_log('FileController::renameFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while renaming file.'], 500);
} finally {
$this->_jsonEnd();
}
}
/**
* @OA\Post(
@@ -452,63 +621,75 @@ class FileController
* @return void Outputs a JSON response.
*/
public function saveFile()
{
header('Content-Type: application/json');
// --- CSRF Protection ---
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = $headersArr['x-csrf-token'] ?? '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// --- Authentication Check ---
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
{
$this->_jsonStart();
try {
if (!$this->_checkCsrf()) return;
if (!$this->_requireAuth()) return;
$username = $_SESSION['username'] ?? '';
// --- Readonly check ---
$userPermissions = loadUserPermissions($username);
if ($username && !empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to save files."]);
exit;
$userPermissions = $this->loadPerms($username);
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
$this->_jsonOut(["error" => "Read-only users are not allowed to save files."], 403);
return;
}
// --- Input parsing ---
$data = json_decode(file_get_contents("php://input"), true);
$data = $this->_readJsonBody();
if (empty($data) || !isset($data["fileName"], $data["content"])) {
http_response_code(400);
echo json_encode(["error" => "Invalid request data", "received" => $data]);
exit;
$this->_jsonOut(["error" => "Invalid request data"], 400);
return;
}
$fileName = basename($data["fileName"]);
$folder = isset($data["folder"]) ? trim($data["folder"]) : "root";
$fileName = basename(trim((string)$data["fileName"]));
$folder = isset($data["folder"]) ? trim((string)$data["folder"]) : "root";
// --- Folder validation ---
if ($fileName === '' || !preg_match(REGEX_FILE_NAME, $fileName)) {
$this->_jsonOut(["error" => "Invalid file name."], 400);
return;
}
if (strtolower($folder) !== "root" && !preg_match(REGEX_FOLDER_NAME, $folder)) {
echo json_encode(["error" => "Invalid folder name"]);
exit;
$this->_jsonOut(["error" => "Invalid folder name"], 400);
return;
}
$folder = trim($folder, "/\\ ");
// --- Delegate to model, passing the uploader ---
// Make sure FileModel::saveFile signature is:
// saveFile(string $folder, string $fileName, $content, ?string $uploader = null)
$result = FileModel::saveFile(
$folder,
$fileName,
$data["content"],
$username // ← pass the real uploader here
);
// Folder-only users may only write within their scope
$dv = $this->enforceFolderScope($folder, $username, $userPermissions);
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
echo json_encode($result);
// If overwriting, enforce ownership for non-admins
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$dir = (strtolower($folder) === 'root') ? $baseDir : $baseDir . DIRECTORY_SEPARATOR . $folder;
$path = $dir . DIRECTORY_SEPARATOR . $fileName;
if (is_file($path)) {
$violation = $this->enforceScopeAndOwnership($folder, [$fileName], $username, $userPermissions);
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
}
// Server-side guard: block saving executable/server-side script types
$deny = ['php','phtml','phar','php3','php4','php5','php7','php8','pht','shtml','cgi','fcgi'];
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
if (in_array($ext, $deny, true)) {
$this->_jsonOut(['error' => 'Saving this file type is not allowed.'], 400);
return;
}
$result = FileModel::saveFile($folder, $fileName, (string)$data["content"], $username);
if (!is_array($result)) {
throw new RuntimeException('FileModel::saveFile returned non-array');
}
if (isset($result['error'])) {
$this->_jsonOut($result, 400);
return;
}
$this->_jsonOut($result);
} catch (Throwable $e) {
error_log('FileController::saveFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while saving file.'], 500);
} finally {
$this->_jsonEnd();
}
}
/**
* @OA\Get(
@@ -582,6 +763,23 @@ class FileController
exit;
}
// Ownership enforcement (allow admin OR bypassOwnership)
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
if (!$ignoreOwnership) {
$meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
http_response_code(403);
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
exit;
}
}
// Retrieve download info from the model.
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
if (isset($downloadInfo['error'])) {
@@ -676,6 +874,13 @@ class FileController
exit;
}
if (!$this->isAdmin($userPermissions) && array_key_exists('canZip', $userPermissions) && !$userPermissions['canZip']) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(["error" => "ZIP downloads are not allowed for your account."]);
exit;
}
// Read and decode JSON input.
$data = json_decode(file_get_contents("php://input"), true);
if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) {
@@ -701,6 +906,22 @@ class FileController
}
}
// Ownership enforcement (allow admin OR bypassOwnership)
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
if (!$ignoreOwnership) {
$meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
http_response_code(403);
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
exit;
}
}
// Create ZIP archive using FileModel.
$result = FileModel::createZipArchive($folder, $files);
if (isset($result['error'])) {
@@ -819,6 +1040,12 @@ class FileController
}
}
// Folder-only users can only extract inside their subtree
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
$dv = $this->enforceFolderScope($folder, $username, $userPermissions);
if ($dv) { http_response_code(403); echo json_encode(["error"=>$dv]); return; }
// Delegate to the model.
$result = FileModel::extractZipArchive($folder, $files);
echo json_encode($result);
@@ -1078,13 +1305,19 @@ class FileController
// Check user permissions.
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if ($username && !empty($userPermissions['readOnly'])) {
$userPermissions = $this->loadPerms($username);
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
http_response_code(403);
echo json_encode(["error" => "Read-only users are not allowed to create share links."]);
exit;
}
if (!$this->isAdmin($userPermissions) && array_key_exists('canShare', $userPermissions) && !$userPermissions['canShare']) {
http_response_code(403);
echo json_encode(["error" => "You are not allowed to create share links."]);
exit;
}
// Parse POST JSON input.
$input = json_decode(file_get_contents("php://input"), true);
if (!$input) {
@@ -1107,6 +1340,23 @@ class FileController
exit;
}
// Non-admins can only share their own files
// Ownership enforcement (allow admin OR bypassOwnership)
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
if (!$ignoreOwnership) {
$meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
http_response_code(403);
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
exit;
}
}
// Convert the provided value+unit into seconds
switch ($unit) {
case 'seconds':
@@ -1349,7 +1599,7 @@ class FileController
// Delegate deletion to the model.
$result = FileModel::deleteTrashFiles($filesToDelete);
// Build a humanfriendly success or error message
// Build a human-friendly success or error message
if (!empty($result['deleted'])) {
$count = count($result['deleted']);
$msg = "Trash item" . ($count === 1 ? "" : "s") . " deleted: " . implode(", ", $result['deleted']);
@@ -1469,7 +1719,7 @@ class FileController
// Check that the user is not read-only.
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
$userPermissions = $this->loadPerms($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to file tags"]);
exit;
@@ -1502,6 +1752,22 @@ class FileController
exit;
}
// Ownership enforcement (allow admin OR bypassOwnership)
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
if (!$ignoreOwnership) {
$meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
http_response_code(403);
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
exit;
}
}
// Delegate to the model.
$result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete);
echo json_encode($result);
@@ -1545,32 +1811,96 @@ class FileController
* @return void Outputs JSON response.
*/
public function getFileList(): void
{
header('Content-Type: application/json');
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
header('Content-Type: application/json; charset=utf-8');
set_error_handler(function ($severity, $message, $file, $line) {
if (!(error_reporting() & $severity)) return;
throw new ErrorException($message, 0, $severity, $file, $line);
});
try {
if (empty($_SESSION['username'])) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
echo json_encode(['error' => 'Unauthorized']);
return;
}
// Retrieve the folder from GET; default to "root".
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
if (!is_dir(META_DIR)) {
@mkdir(META_DIR, 0775, true);
}
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
echo json_encode(['error' => 'Invalid folder name.']);
return;
}
if (!is_dir(UPLOAD_DIR)) {
http_response_code(500);
echo json_encode(['error' => 'Uploads directory not found.']);
return;
}
// Delegate to the model.
$result = FileModel::getFileList($folder);
if ($result === false || $result === null) {
http_response_code(500);
echo json_encode(['error' => 'File model failed.']);
return;
}
if (!is_array($result)) {
throw new RuntimeException('FileModel::getFileList returned a non-array.');
}
if (isset($result['error'])) {
http_response_code(400);
echo json_encode($result);
return;
}
echo json_encode($result);
exit;
// --- viewOwnOnly (for non-admins) ---
$username = $_SESSION['username'] ?? '';
$perms = $this->loadPerms($username);
$admin = $this->isAdmin($perms);
$ownOnly = !$admin && !empty($perms['viewOwnOnly']);
if ($ownOnly && isset($result['files'])) {
$files = $result['files'];
if (is_array($files) && array_keys($files) !== range(0, count($files) - 1)) {
// associative: name => meta
$filtered = [];
foreach ($files as $name => $meta) {
if (!isset($meta['uploader']) || strcasecmp((string)$meta['uploader'], $username) === 0) {
$filtered[$name] = $meta;
}
}
$result['files'] = $filtered;
} elseif (is_array($files)) {
// list of objects
$result['files'] = array_values(array_filter($files, function ($f) use ($username) {
return !isset($f['uploader']) || strcasecmp((string)$f['uploader'], $username) === 0;
}));
}
}
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
} catch (Throwable $e) {
error_log('FileController::getFileList error: ' . $e->getMessage() .
' in ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
echo json_encode(['error' => 'Internal server error while listing files.']);
} finally {
restore_error_handler();
}
}
/**
* GET /api/file/getShareLinks.php
@@ -1631,26 +1961,44 @@ class FileController
* POST /api/file/createFile.php
*/
public function createFile(): void
{
{
$this->_jsonStart();
try {
if (!$this->_requireAuth()) return;
// Check user permissions (assuming loadUserPermissions() is available).
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if (!empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to create files."]);
exit;
$userPermissions = $this->loadPerms($username);
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
$this->_jsonOut(["error" => "Read-only users are not allowed to create files."], 403);
return;
}
$body = json_decode(file_get_contents('php://input'), true);
$folder = $body['folder'] ?? 'root';
$filename = $body['name'] ?? '';
$result = FileModel::createFile($folder, $filename, $_SESSION['username'] ?? 'Unknown');
$body = $this->_readJsonBody();
$folder = isset($body['folder']) ? trim((string)$body['folder']) : 'root';
$filename = isset($body['name']) ? basename(trim((string)$body['name'])) : '';
if (!$result['success']) {
http_response_code($result['code'] ?? 400);
echo json_encode(['success'=>false,'error'=>$result['error']]);
} else {
echo json_encode(['success'=>true]);
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
$this->_jsonOut(["error" => "Invalid folder name."], 400); return;
}
if ($filename === '' || !preg_match(REGEX_FILE_NAME, $filename)) {
$this->_jsonOut(["error" => "Invalid file name."], 400); return;
}
$dv = $this->enforceFolderScope($folder, $username, $userPermissions);
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
$result = FileModel::createFile($folder, $filename, $username);
if (empty($result['success'])) {
$this->_jsonOut(['success'=>false,'error'=>$result['error'] ?? 'Failed to create file'], $result['code'] ?? 400);
return;
}
$this->_jsonOut(['success'=>true]);
} catch (Throwable $e) {
error_log('FileController::createFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while creating file.'], 500);
} finally {
$this->_jsonEnd();
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -6,25 +6,60 @@ require_once PROJECT_ROOT . '/config/config.php';
class AdminModel
{
/**
* Parse a shorthand size value (e.g. "5G", "500M", "123K") into bytes.
* Parse a shorthand size value (e.g. "5G", "500M", "123K", "50MB", "10KiB") into bytes.
* Accepts bare numbers (bytes) and common suffixes: K, KB, KiB, M, MB, MiB, G, GB, GiB, etc.
*
* @param string $val
* @return int
* @return int Bytes (rounded)
*/
private static function parseSize(string $val): int
{
$unit = strtolower(substr($val, -1));
$num = (int) rtrim($val, 'bkmgtpezyBKMGTPESY');
switch ($unit) {
case 'g':
return $num * 1024 ** 3;
case 'm':
return $num * 1024 ** 2;
case 'k':
return $num * 1024;
default:
return $num;
$val = trim($val);
if ($val === '') {
return 0;
}
// Match: number + optional unit/suffix (K, KB, KiB, M, MB, MiB, G, GB, GiB, ...)
if (preg_match('/^\s*(\d+(?:\.\d+)?)\s*([kmgtpezy]?i?b?)?\s*$/i', $val, $m)) {
$num = (float)$m[1];
$unit = strtolower($m[2] ?? '');
switch ($unit) {
case 'k': case 'kb': case 'kib':
$num *= 1024;
break;
case 'm': case 'mb': case 'mib':
$num *= 1024 ** 2;
break;
case 'g': case 'gb': case 'gib':
$num *= 1024 ** 3;
break;
case 't': case 'tb': case 'tib':
$num *= 1024 ** 4;
break;
case 'p': case 'pb': case 'pib':
$num *= 1024 ** 5;
break;
case 'e': case 'eb': case 'eib':
$num *= 1024 ** 6;
break;
case 'z': case 'zb': case 'zib':
$num *= 1024 ** 7;
break;
case 'y': case 'yb': case 'yib':
$num *= 1024 ** 8;
break;
// case 'b' or empty => bytes; do nothing
default:
// If unit is just 'b' or empty, treat as bytes.
// For unknown units fall back to bytes.
break;
}
return (int) round($num);
}
// Fallback: cast any unrecognized input to int (bytes)
return (int)$val;
}
/**
@@ -35,17 +70,22 @@ class AdminModel
*/
public static function updateConfig(array $configUpdate): array
{
// New: only enforce OIDC fields when OIDC is enabled
// Ensure encryption key exists
if (empty($GLOBALS['encryptionKey']) || !is_string($GLOBALS['encryptionKey'])) {
return ["error" => "Server encryption key is not configured."];
}
// Only enforce OIDC fields when OIDC is enabled
$oidcDisabled = isset($configUpdate['loginOptions']['disableOIDCLogin'])
? (bool)$configUpdate['loginOptions']['disableOIDCLogin']
: true; // default to disabled when not present
? (bool)$configUpdate['loginOptions']['disableOIDCLogin']
: true; // default to disabled when not present
if (!$oidcDisabled) {
$oidc = $configUpdate['oidc'] ?? [];
$required = ['providerUrl','clientId','clientSecret','redirectUri'];
foreach ($required as $k) {
if (empty($oidc[$k]) || !is_string($oidc[$k])) {
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."];
$oidc = $configUpdate['oidc'] ?? [];
$required = ['providerUrl','clientId','clientSecret','redirectUri'];
foreach ($required as $k) {
if (empty($oidc[$k]) || !is_string($oidc[$k])) {
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."];
}
}
}
@@ -72,7 +112,7 @@ class AdminModel
$configUpdate['sharedMaxUploadSize'] = $sms;
}
// ── NEW: normalize authBypass & authHeaderName ─────────────────────────
// Normalize authBypass & authHeaderName
if (!isset($configUpdate['loginOptions']['authBypass'])) {
$configUpdate['loginOptions']['authBypass'] = false;
}
@@ -85,10 +125,8 @@ class AdminModel
) {
$configUpdate['loginOptions']['authHeaderName'] = 'X-Remote-User';
} else {
$configUpdate['loginOptions']['authHeaderName'] =
trim($configUpdate['loginOptions']['authHeaderName']);
$configUpdate['loginOptions']['authHeaderName'] = trim($configUpdate['loginOptions']['authHeaderName']);
}
// ───────────────────────────────────────────────────────────────────────────
// Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
@@ -109,7 +147,7 @@ class AdminModel
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
// Attempt a cleanup: delete the old file and try again.
if (file_exists($configFile)) {
unlink($configFile);
@unlink($configFile);
}
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
error_log("AdminModel::updateConfig: Failed to write configuration even after deletion.");
@@ -130,13 +168,15 @@ class AdminModel
public static function getConfig(): array
{
$configFile = USERS_DIR . 'adminConfig.json';
if (file_exists($configFile)) {
$encryptedContent = file_get_contents($configFile);
$decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']);
if ($decryptedContent === false) {
http_response_code(500);
// Do not set HTTP status here; let the controller decide.
return ["error" => "Failed to decrypt configuration."];
}
$config = json_decode($decryptedContent, true);
if (!is_array($config)) {
$config = [];
@@ -144,7 +184,7 @@ class AdminModel
// Normalize login options if missing
if (!isset($config['loginOptions'])) {
// migrate legacy top-level flags; default OIDC to true (disabled)
// Migrate legacy top-level flags; default OIDC to true (disabled)
$config['loginOptions'] = [
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
@@ -152,13 +192,14 @@ class AdminModel
];
unset($config['disableFormLogin'], $config['disableBasicAuth'], $config['disableOIDCLogin']);
} else {
// normalize booleans; default OIDC to true (disabled) if missing
// Normalize booleans; default OIDC to true (disabled) if missing
$lo = &$config['loginOptions'];
$lo['disableFormLogin'] = isset($lo['disableFormLogin']) ? (bool)$lo['disableFormLogin'] : false;
$lo['disableBasicAuth'] = isset($lo['disableBasicAuth']) ? (bool)$lo['disableBasicAuth'] : false;
$lo['disableOIDCLogin'] = isset($lo['disableOIDCLogin']) ? (bool)$lo['disableOIDCLogin'] : true;
}
// Ensure OIDC structure exists
if (!isset($config['oidc']) || !is_array($config['oidc'])) {
$config['oidc'] = [
'providerUrl' => '',
@@ -174,6 +215,7 @@ class AdminModel
}
}
// Normalize authBypass & authHeaderName
if (!array_key_exists('authBypass', $config['loginOptions'])) {
$config['loginOptions']['authBypass'] = false;
} else {
@@ -191,38 +233,41 @@ class AdminModel
if (!isset($config['globalOtpauthUrl'])) {
$config['globalOtpauthUrl'] = "";
}
if (!isset($config['header_title']) || empty($config['header_title'])) {
if (!isset($config['header_title']) || $config['header_title'] === '') {
$config['header_title'] = "FileRise";
}
if (!isset($config['enableWebDAV'])) {
$config['enableWebDAV'] = false;
}
// Default sharedMaxUploadSize to 50MB or TOTAL_UPLOAD_SIZE if smaller
if (!isset($config['sharedMaxUploadSize'])) {
$defaultSms = min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE));
$config['sharedMaxUploadSize'] = $defaultSms;
// sharedMaxUploadSize: default if missing; clamp if present
$maxBytes = self::parseSize(TOTAL_UPLOAD_SIZE);
if (!isset($config['sharedMaxUploadSize']) || !is_numeric($config['sharedMaxUploadSize']) || $config['sharedMaxUploadSize'] < 1) {
$config['sharedMaxUploadSize'] = min(50 * 1024 * 1024, $maxBytes);
} else {
$config['sharedMaxUploadSize'] = (int)min((int)$config['sharedMaxUploadSize'], $maxBytes);
}
return $config;
} else {
// Return defaults.
return [
'header_title' => "FileRise",
'oidc' => [
'providerUrl' => 'https://your-oidc-provider.com',
'clientId' => '',
'clientSecret' => '',
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
],
'loginOptions' => [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableOIDCLogin' => true
],
'globalOtpauthUrl' => "",
'enableWebDAV' => false,
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE))
];
}
// No config on disk; return defaults.
return [
'header_title' => "FileRise",
'oidc' => [
'providerUrl' => 'https://your-oidc-provider.com',
'clientId' => '',
'clientSecret' => '',
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
],
'loginOptions' => [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableOIDCLogin' => true
],
'globalOtpauthUrl' => "",
'enableWebDAV' => false,
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE))
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,58 +6,61 @@ require_once PROJECT_ROOT . '/config/config.php';
class FolderModel
{
/**
* Creates a folder under the specified parent (or in root) and creates an empty metadata file.
* Resolve a (possibly nested) relative folder like "invoices/2025" to a real path
* under UPLOAD_DIR. Validates each path segment against REGEX_FOLDER_NAME, enforces
* containment, and (optionally) creates the folder.
*
* @param string $folderName The name of the folder to create.
* @param string $parent (Optional) The parent folder name. Defaults to empty.
* @return array Returns an array with a "success" key if the folder was created,
* or an "error" key if an error occurred.
* @param string $folder Relative folder or "root"
* @param bool $create Create the folder if missing
* @return array [string|null $realPath, string $relative, string|null $error]
*/
public static function createFolder(string $folderName, string $parent = ""): array
private static function resolveFolderPath(string $folder, bool $create = false): array
{
$folderName = trim($folderName);
$parent = trim($parent);
$folder = trim($folder) ?: 'root';
$relative = 'root';
// Validate folder name (only letters, numbers, underscores, dashes, and spaces allowed).
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
return ["error" => "Invalid folder name."];
}
if ($parent !== "" && !preg_match(REGEX_FOLDER_NAME, $parent)) {
return ["error" => "Invalid parent folder name."];
$base = realpath(UPLOAD_DIR);
if ($base === false) {
return [null, 'root', "Uploads directory not configured correctly."];
}
$baseDir = rtrim(UPLOAD_DIR, '/\\');
if ($parent !== "" && strtolower($parent) !== "root") {
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName;
$relativePath = $parent . "/" . $folderName;
if (strtolower($folder) === 'root') {
$dir = $base;
} else {
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $folderName;
$relativePath = $folderName;
}
// Check if the folder already exists.
if (file_exists($fullPath)) {
return ["error" => "Folder already exists."];
}
// Attempt to create the folder.
if (mkdir($fullPath, 0755, true)) {
// Create an empty metadata file for the new folder.
$metadataFile = self::getMetadataFilePath($relativePath);
if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT)) === false) {
return ["error" => "Folder created but failed to create metadata file."];
// validate each segment against REGEX_FOLDER_NAME
$parts = array_filter(explode('/', trim($folder, "/\\ ")), fn($p) => $p !== '');
if (empty($parts)) {
return [null, 'root', "Invalid folder name."];
}
return ["success" => true];
} else {
return ["error" => "Failed to create folder."];
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
return [null, 'root', "Invalid folder name."];
}
}
$relative = implode('/', $parts);
$dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
}
if (!is_dir($dir)) {
if ($create) {
if (!mkdir($dir, 0775, true)) {
return [null, $relative, "Failed to create folder."];
}
} else {
return [null, $relative, "Folder does not exist."];
}
}
$real = realpath($dir);
if ($real === false || strpos($real, $base) !== 0) {
return [null, $relative, "Invalid folder path."];
}
return [$real, $relative, null];
}
/**
* Generates the metadata file path for a given folder.
*
* @param string $folder The relative folder path.
* @return string The metadata file path.
* Build metadata file path for a given (relative) folder.
*/
private static function getMetadataFilePath(string $folder): string
{
@@ -67,134 +70,146 @@ class FolderModel
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
}
/**
* Creates a folder under the specified parent (or in root) and creates an empty metadata file.
*/
public static function createFolder(string $folderName, string $parent = ""): array
{
$folderName = trim($folderName);
$parent = trim($parent);
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
return ["error" => "Invalid folder name."];
}
// Resolve parent path (root ok; nested ok)
[$parentReal, $parentRel, $err] = self::resolveFolderPath($parent === '' ? 'root' : $parent, true);
if ($err) return ["error" => $err];
$targetRel = ($parentRel === 'root') ? $folderName : ($parentRel . '/' . $folderName);
$targetDir = $parentReal . DIRECTORY_SEPARATOR . $folderName;
if (file_exists($targetDir)) {
return ["error" => "Folder already exists."];
}
if (!mkdir($targetDir, 0775, true)) {
return ["error" => "Failed to create folder."];
}
// Create an empty metadata file for the new folder.
$metadataFile = self::getMetadataFilePath($targetRel);
if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT), LOCK_EX) === false) {
return ["error" => "Folder created but failed to create metadata file."];
}
return ["success" => true];
}
/**
* Deletes a folder if it is empty and removes its corresponding metadata.
*
* @param string $folder The folder name (relative to the upload directory).
* @return array An associative array with "success" on success or "error" on failure.
*/
public static function deleteFolder(string $folder): array
{
// Prevent deletion of "root".
if (strtolower($folder) === 'root') {
return ["error" => "Cannot delete root folder."];
}
// Validate folder name.
if (!preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name."];
}
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
if ($err) return ["error" => $err];
// Build the full folder path.
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
// Check if the folder exists and is a directory.
if (!file_exists($folderPath) || !is_dir($folderPath)) {
return ["error" => "Folder does not exist."];
}
// Prevent deletion if the folder is not empty.
$items = array_diff(scandir($folderPath), array('.', '..'));
// Prevent deletion if not empty.
$items = array_diff(scandir($real), array('.', '..'));
if (count($items) > 0) {
return ["error" => "Folder is not empty."];
}
// Attempt to delete the folder.
if (rmdir($folderPath)) {
// Remove corresponding metadata file.
$metadataFile = self::getMetadataFilePath($folder);
if (file_exists($metadataFile)) {
unlink($metadataFile);
}
return ["success" => true];
} else {
if (!rmdir($real)) {
return ["error" => "Failed to delete folder."];
}
// Remove metadata file (best-effort).
$metadataFile = self::getMetadataFilePath($relative);
if (file_exists($metadataFile)) {
@unlink($metadataFile);
}
return ["success" => true];
}
/**
* Renames a folder and updates related metadata files.
*
* @param string $oldFolder The current folder name (relative to UPLOAD_DIR).
* @param string $newFolder The new folder name.
* @return array Returns an associative array with "success" on success or "error" on failure.
* Renames a folder and updates related metadata files (by renaming their filenames).
*/
public static function renameFolder(string $oldFolder, string $newFolder): array
{
// Sanitize and trim folder names.
$oldFolder = trim($oldFolder, "/\\ ");
$newFolder = trim($newFolder, "/\\ ");
// Validate folder names.
if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) {
return ["error" => "Invalid folder name(s)."];
// Validate names (per-segment)
foreach ([$oldFolder, $newFolder] as $f) {
$parts = array_filter(explode('/', $f), fn($p)=>$p!=='');
if (empty($parts)) return ["error" => "Invalid folder name(s)."];
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
return ["error" => "Invalid folder name(s)."];
}
}
}
// Build the full folder paths.
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$oldPath = $baseDir . DIRECTORY_SEPARATOR . $oldFolder;
$newPath = $baseDir . DIRECTORY_SEPARATOR . $newFolder;
[$oldReal, $oldRel, $err] = self::resolveFolderPath($oldFolder, false);
if ($err) return ["error" => $err];
// Validate that the old folder exists and new folder does not.
if ((realpath($oldPath) === false) || (realpath(dirname($newPath)) === false) ||
strpos(realpath($oldPath), realpath($baseDir)) !== 0 ||
strpos(realpath(dirname($newPath)), realpath($baseDir)) !== 0
) {
$base = realpath(UPLOAD_DIR);
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p!=='');
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
// Parent of new path must exist
$newParent = dirname($newPath);
if (!is_dir($newParent) || strpos(realpath($newParent), $base) !== 0) {
return ["error" => "Invalid folder path."];
}
if (!file_exists($oldPath) || !is_dir($oldPath)) {
return ["error" => "Folder to rename does not exist."];
}
if (file_exists($newPath)) {
return ["error" => "New folder name already exists."];
}
// Attempt to rename the folder.
if (rename($oldPath, $newPath)) {
// Update metadata: Rename all metadata files that have the old folder prefix.
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldFolder);
$newPrefix = str_replace(['/', '\\', ' '], '-', $newFolder);
$metadataFiles = glob(META_DIR . $oldPrefix . '*_metadata.json');
foreach ($metadataFiles as $oldMetaFile) {
$baseName = basename($oldMetaFile);
$newBaseName = preg_replace('/^' . preg_quote($oldPrefix, '/') . '/', $newPrefix, $baseName);
$newMetaFile = META_DIR . $newBaseName;
rename($oldMetaFile, $newMetaFile);
}
return ["success" => true];
} else {
if (!rename($oldReal, $newPath)) {
return ["error" => "Failed to rename folder."];
}
// Update metadata filenames (prefix-rename)
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldRel);
$newPrefix = str_replace(['/', '\\', ' '], '-', implode('/', $newParts));
$globPat = META_DIR . $oldPrefix . '*_metadata.json';
$metadataFiles = glob($globPat) ?: [];
foreach ($metadataFiles as $oldMetaFile) {
$baseName = basename($oldMetaFile);
$newBase = preg_replace('/^' . preg_quote($oldPrefix, '/') . '/', $newPrefix, $baseName);
$newMeta = META_DIR . $newBase;
@rename($oldMetaFile, $newMeta);
}
return ["success" => true];
}
/**
* Recursively scans a directory for subfolders.
*
* @param string $dir The full path to the directory.
* @param string $relative The relative path from the base directory.
* @return array An array of folder paths (relative to the base).
* Recursively scans a directory for subfolders (relative paths).
*/
private static function getSubfolders(string $dir, string $relative = ''): array
{
$folders = [];
$items = scandir($dir);
$safeFolderNamePattern = REGEX_FOLDER_NAME;
$items = @scandir($dir) ?: [];
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
if (!preg_match($safeFolderNamePattern, $item)) {
continue;
}
if ($item === '.' || $item === '..') continue;
if (!preg_match(REGEX_FOLDER_NAME, $item)) continue;
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) {
$folderPath = ($relative ? $relative . '/' : '') . $item;
$folders[] = $folderPath;
$subFolders = self::getSubfolders($path, $folderPath);
$folders = array_merge($folders, $subFolders);
$folders[] = $folderPath;
$folders = array_merge($folders, self::getSubfolders($path, $folderPath));
}
}
return $folders;
@@ -202,35 +217,31 @@ class FolderModel
/**
* Retrieves the list of folders (including "root") along with file count metadata.
*
* @return array An array of folder information arrays.
*/
public static function getFolderList(): array
{
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) {
return []; // or ["error" => "..."]
}
$folderInfoList = [];
// Process the "root" folder.
$rootMetaFile = self::getMetadataFilePath('root');
$rootFileCount = 0;
// root
$rootMetaFile = self::getMetadataFilePath('root');
$rootFileCount = 0;
if (file_exists($rootMetaFile)) {
$rootMetadata = json_decode(file_get_contents($rootMetaFile), true);
$rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0;
}
$folderInfoList[] = [
"folder" => "root",
"fileCount" => $rootFileCount,
"folder" => "root",
"fileCount" => $rootFileCount,
"metadataFile" => basename($rootMetaFile)
];
// Recursively scan for subfolders.
if (is_dir($baseDir)) {
$subfolders = self::getSubfolders($baseDir);
} else {
$subfolders = [];
}
// For each subfolder, load metadata to get file counts.
// subfolders
$subfolders = is_dir($baseDir) ? self::getSubfolders($baseDir) : [];
foreach ($subfolders as $folder) {
$metaFile = self::getMetadataFilePath($folder);
$fileCount = 0;
@@ -239,8 +250,8 @@ class FolderModel
$fileCount = is_array($metadata) ? count($metadata) : 0;
}
$folderInfoList[] = [
"folder" => $folder,
"fileCount" => $fileCount,
"folder" => $folder,
"fileCount" => $fileCount,
"metadataFile" => basename($metaFile)
];
}
@@ -250,136 +261,101 @@ class FolderModel
/**
* Retrieves the share folder record for a given token.
*
* @param string $token The share folder token.
* @return array|null The share folder record, or null if not found.
*/
public static function getShareFolderRecord(string $token): ?array
{
$shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) {
return null;
}
if (!file_exists($shareFile)) return null;
$shareLinks = json_decode(file_get_contents($shareFile), true);
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
return null;
}
return $shareLinks[$token];
return (is_array($shareLinks) && isset($shareLinks[$token])) ? $shareLinks[$token] : null;
}
/**
* Retrieves shared folder data based on a share token.
*
* @param string $token The share folder token.
* @param string|null $providedPass The provided password (if any).
* @param int $page The page number for pagination.
* @param int $itemsPerPage The number of files to display per page.
* @return array Associative array with keys:
* - 'record': the share record,
* - 'folder': the shared folder (relative),
* - 'realFolderPath': absolute folder path,
* - 'files': array of filenames for the current page,
* - 'currentPage': current page number,
* - 'totalPages': total pages,
* or an 'error' key on failure.
*/
public static function getSharedFolderData(string $token, ?string $providedPass, int $page = 1, int $itemsPerPage = 10): array
{
// Load the share folder record.
$shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) {
return ["error" => "Share link not found."];
}
if (!file_exists($shareFile)) return ["error" => "Share link not found."];
$shareLinks = json_decode(file_get_contents($shareFile), true);
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
return ["error" => "Share link not found."];
}
$record = $shareLinks[$token];
// Check expiration.
if (time() > $record['expires']) {
if (time() > ($record['expires'] ?? 0)) {
return ["error" => "This share link has expired."];
}
// If password protection is enabled and no password is provided, signal that.
if (!empty($record['password']) && empty($providedPass)) {
return ["needs_password" => true];
}
if (!empty($record['password']) && !password_verify($providedPass, $record['password'])) {
return ["error" => "Invalid password."];
}
// Determine the shared folder.
$folder = trim($record['folder'], "/\\ ");
$baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) {
return ["error" => "Uploads directory not configured correctly."];
}
if (!empty($folder) && strtolower($folder) !== 'root') {
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
} else {
$folder = "root";
$folderPath = $baseDir;
}
$realFolderPath = realpath($folderPath);
$uploadDirReal = realpath(UPLOAD_DIR);
if ($realFolderPath === false || strpos($realFolderPath, $uploadDirReal) !== 0 || !is_dir($realFolderPath)) {
// Resolve shared folder
$folder = trim((string)$record['folder'], "/\\ ");
[$realFolderPath, $relative, $err] = self::resolveFolderPath($folder === '' ? 'root' : $folder, false);
if ($err || !is_dir($realFolderPath)) {
return ["error" => "Shared folder not found."];
}
// Scan for files (only files).
$allFiles = array_values(array_filter(scandir($realFolderPath), function ($item) use ($realFolderPath) {
return is_file($realFolderPath . DIRECTORY_SEPARATOR . $item);
}));
sort($allFiles);
$totalFiles = count($allFiles);
$totalPages = max(1, ceil($totalFiles / $itemsPerPage));
$currentPage = min($page, $totalPages);
$startIndex = ($currentPage - 1) * $itemsPerPage;
// List files (safe names only; skip hidden)
$all = @scandir($realFolderPath) ?: [];
$allFiles = [];
foreach ($all as $it) {
if ($it === '.' || $it === '..') continue;
if ($it[0] === '.') continue;
if (!preg_match(REGEX_FILE_NAME, $it)) continue;
if (is_file($realFolderPath . DIRECTORY_SEPARATOR . $it)) {
$allFiles[] = $it;
}
}
sort($allFiles, SORT_NATURAL | SORT_FLAG_CASE);
$totalFiles = count($allFiles);
$totalPages = max(1, (int)ceil($totalFiles / max(1, $itemsPerPage)));
$currentPage = min(max(1, $page), $totalPages);
$startIndex = ($currentPage - 1) * $itemsPerPage;
$filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage);
return [
"record" => $record,
"folder" => $folder,
"realFolderPath" => $realFolderPath,
"files" => $filesOnPage,
"currentPage" => $currentPage,
"totalPages" => $totalPages
"record" => $record,
"folder" => $relative,
"realFolderPath"=> $realFolderPath,
"files" => $filesOnPage,
"currentPage" => $currentPage,
"totalPages" => $totalPages
];
}
/**
* Creates a share link for a folder.
*
* @param string $folder The folder to share (relative to UPLOAD_DIR).
* @param int $expirationSeconds How many seconds until expiry.
* @param string $password Optional password.
* @param int $allowUpload 0 or 1 whether uploads are allowed.
* @return array ["token","expires","link"] on success, or ["error"].
*/
public static function createShareFolderLink(string $folder, int $expirationSeconds = 3600, string $password = "", int $allowUpload = 0): array
{
// Validate folder
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name."];
}
// Validate folder (and ensure it exists)
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
if ($err) return ["error" => $err];
// Token
try {
$token = bin2hex(random_bytes(16));
} catch (Exception $e) {
} catch (\Throwable $e) {
return ["error" => "Could not generate token."];
}
// Expiry
$expires = time() + $expirationSeconds;
$expires = time() + max(1, $expirationSeconds);
$hashedPassword= $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
// Password hash
$hashedPassword = $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
// Load existing
$shareFile = META_DIR . "share_folder_links.json";
$links = file_exists($shareFile)
? json_decode(file_get_contents($shareFile), true) ?? []
? (json_decode(file_get_contents($shareFile), true) ?? [])
: [];
// Cleanup
// cleanup expired
$now = time();
foreach ($links as $k => $v) {
if (!empty($v['expires']) && $v['expires'] < $now) {
@@ -387,107 +363,78 @@ class FolderModel
}
}
// Add new
$links[$token] = [
"folder" => $folder,
"folder" => $relative,
"expires" => $expires,
"password" => $hashedPassword,
"allowUpload" => $allowUpload
"allowUpload" => $allowUpload ? 1 : 0
];
// Save
if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)) === false) {
if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX) === false) {
return ["error" => "Could not save share link."];
}
// Build URL
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
$baseUrl = $protocol . '://' . rtrim($host, '/');
$link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token);
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
$scheme = $https ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
$baseUrl = $scheme . '://' . rtrim($host, '/');
$link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token);
return ["token" => $token, "expires" => $expires, "link" => $link];
}
/**
* Retrieves information for a shared file from a shared folder link.
*
* @param string $token The share folder token.
* @param string $file The requested file name.
* @return array An associative array with keys:
* - "error": error message, if any,
* - "realFilePath": the absolute path to the file,
* - "mimeType": the detected MIME type.
*/
public static function getSharedFileInfo(string $token, string $file): array
{
// Load the share folder record.
$shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) {
return ["error" => "Share link not found."];
}
if (!file_exists($shareFile)) return ["error" => "Share link not found."];
$shareLinks = json_decode(file_get_contents($shareFile), true);
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
return ["error" => "Share link not found."];
}
$record = $shareLinks[$token];
// Check if the link has expired.
if (time() > $record['expires']) {
if (time() > ($record['expires'] ?? 0)) {
return ["error" => "This share link has expired."];
}
// Determine the shared folder.
$folder = trim($record['folder'], "/\\ ");
$baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) {
return ["error" => "Uploads directory not configured correctly."];
}
if (!empty($folder) && strtolower($folder) !== 'root') {
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
} else {
$folderPath = $baseDir;
}
$realFolderPath = realpath($folderPath);
$uploadDirReal = realpath(UPLOAD_DIR);
if ($realFolderPath === false || strpos($realFolderPath, $uploadDirReal) !== 0 || !is_dir($realFolderPath)) {
[$realFolderPath, , $err] = self::resolveFolderPath((string)$record['folder'], false);
if ($err || !is_dir($realFolderPath)) {
return ["error" => "Shared folder not found."];
}
// Sanitize the file name to prevent path traversal.
if (strpos($file, "/") !== false || strpos($file, "\\") !== false) {
$file = basename(trim($file));
if (!preg_match(REGEX_FILE_NAME, $file)) {
return ["error" => "Invalid file name."];
}
$file = basename($file);
// Build the full file path.
$filePath = $realFolderPath . DIRECTORY_SEPARATOR . $file;
$realFilePath = realpath($filePath);
if ($realFilePath === false || strpos($realFilePath, $realFolderPath) !== 0 || !is_file($realFilePath)) {
$full = $realFolderPath . DIRECTORY_SEPARATOR . $file;
$real = realpath($full);
if ($real === false || strpos($real, $realFolderPath) !== 0 || !is_file($real)) {
return ["error" => "File not found."];
}
$mimeType = mime_content_type($realFilePath);
return [
"realFilePath" => $realFilePath,
"mimeType" => $mimeType
];
$mime = function_exists('mime_content_type') ? mime_content_type($real) : 'application/octet-stream';
return ["realFilePath" => $real, "mimeType" => $mime];
}
/**
* Handles uploading a file to a shared folder.
*
* @param string $token The share folder token.
* @param array $fileUpload The $_FILES['fileToUpload'] array.
* @return array An associative array with "success" on success or "error" on failure.
*/
public static function uploadToSharedFolder(string $token, array $fileUpload): array
{
// Define maximum file size and allowed extensions.
// Max size & allowed extensions (mirror FileModels common types)
$maxSize = 50 * 1024 * 1024; // 50 MB
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'txt', 'xls', 'xlsx', 'ppt', 'pptx', 'mp4', 'webm', 'mp3', 'mkv'];
$allowedExtensions = [
'jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx',
'mp4','webm','mp3','mkv','csv','json','xml','md'
];
// Load the share folder record.
$shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) {
return ["error" => "Share record not found."];
@@ -498,75 +445,50 @@ class FolderModel
}
$record = $shareLinks[$token];
// Check expiration.
if (time() > $record['expires']) {
if (time() > ($record['expires'] ?? 0)) {
return ["error" => "This share link has expired."];
}
// Check whether uploads are allowed.
if (empty($record['allowUpload']) || $record['allowUpload'] != 1) {
if (empty($record['allowUpload']) || (int)$record['allowUpload'] !== 1) {
return ["error" => "File uploads are not allowed for this share."];
}
// Validate file upload presence.
if ($fileUpload['error'] !== UPLOAD_ERR_OK) {
return ["error" => "File upload error. Code: " . $fileUpload['error']];
if (($fileUpload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ["error" => "File upload error. Code: " . (int)$fileUpload['error']];
}
if ($fileUpload['size'] > $maxSize) {
if (($fileUpload['size'] ?? 0) > $maxSize) {
return ["error" => "File size exceeds allowed limit."];
}
$uploadedName = basename($fileUpload['name']);
$uploadedName = basename((string)($fileUpload['name'] ?? ''));
$ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExtensions)) {
if (!in_array($ext, $allowedExtensions, true)) {
return ["error" => "File type not allowed."];
}
// Determine the target folder from the share record.
$folderName = trim($record['folder'], "/\\");
$targetFolder = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
if (!empty($folderName) && strtolower($folderName) !== 'root') {
$targetFolder .= $folderName;
}
// Resolve target folder
[$targetDir, $relative, $err] = self::resolveFolderPath((string)$record['folder'], true);
if ($err) return ["error" => $err];
// Verify target folder exists.
$realTargetFolder = realpath($targetFolder);
$uploadDirReal = realpath(UPLOAD_DIR);
if ($realTargetFolder === false || strpos($realTargetFolder, $uploadDirReal) !== 0 || !is_dir($realTargetFolder)) {
return ["error" => "Shared folder not found."];
}
// New safe filename
$safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
$newFilename= uniqid('', true) . "_" . $safeBase;
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
// Generate a new filename (using uniqid and sanitizing the original name).
$newFilename = uniqid() . "_" . preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
$targetPath = $realTargetFolder . DIRECTORY_SEPARATOR . $newFilename;
// Move the uploaded file.
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
return ["error" => "Failed to move the uploaded file."];
}
// --- Metadata Update ---
// Determine metadata file.
$metadataKey = (empty($folderName) || strtolower($folderName) === "root") ? "root" : $folderName;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
$metadataCollection = [];
if (file_exists($metadataFile)) {
$data = file_get_contents($metadataFile);
$metadataCollection = json_decode($data, true);
if (!is_array($metadataCollection)) {
$metadataCollection = [];
}
}
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = "Outside Share"; // As per your original implementation.
// Update metadata with the new file's info.
$metadataCollection[$newFilename] = [
"uploaded" => $uploadedDate,
"uploader" => $uploader
// Update metadata (uploaded + modified + uploader)
$metadataFile = self::getMetadataFilePath($relative);
$meta = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
$now = date(DATE_TIME_FORMAT);
$meta[$newFilename] = [
"uploaded" => $now,
"modified" => $now,
"uploader" => "Outside Share"
];
file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT));
file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX);
return ["success" => "File uploaded successfully.", "newFilename" => $newFilename];
}
@@ -574,9 +496,7 @@ class FolderModel
public static function getAllShareFolderLinks(): array
{
$shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) {
return [];
}
if (!file_exists($shareFile)) return [];
$links = json_decode(file_get_contents($shareFile), true);
return is_array($links) ? $links : [];
}
@@ -584,15 +504,13 @@ class FolderModel
public static function deleteShareFolderLink(string $token): bool
{
$shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) {
return false;
}
if (!file_exists($shareFile)) return false;
$links = json_decode(file_get_contents($shareFile), true);
if (!is_array($links) || !isset($links[$token])) {
return false;
}
if (!is_array($links) || !isset($links[$token])) return false;
unset($links[$token]);
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT));
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX);
return true;
}
}
}

View File

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