From d29900d6baca0eacbafc7d52780d42af09cb12c3 Mon Sep 17 00:00:00 2001 From: Ryan Date: Mon, 20 Oct 2025 02:28:03 -0400 Subject: [PATCH] =?UTF-8?q?security(acl):=20enforce=20folder-scope=20&=20o?= =?UTF-8?q?wn-only;=20fix=20file=20list=20=E2=80=9CSelect=20All=E2=80=9D;?= =?UTF-8?q?=20harden=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 43 +- public/js/adminPanel.js | 2 +- public/js/fileListView.js | 56 ++ src/controllers/FileController.php | 825 ++++++++++++++++----------- src/controllers/FolderController.php | 290 ++++++---- 5 files changed, 750 insertions(+), 466 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5299f3..031b717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,46 @@ # Changelog +## Changes 10/20/2025 (v1.5.3) + +security(acl): enforce folder-scope & own-only; fix file list “Select All”; harden ops + +### fileListView.js (v1.5.3) + +- Restore master “Select All” checkbox behavior and row highlighting. +- Keep selection working with own-only filtered lists. +- Build preview/thumb URLs via secure API endpoints; avoid direct /uploads. +- Minor UI polish: slider wiring and pagination focus handling. + +### FileController.php (v1.5.3) + +- Add enforceFolderScope($folder, $user, $perms, $need) and apply across actions. +- Copy/Move: require read on source, write on destination; apply scope on both. +- When user only has read_own, enforce per-file ownership (uploader==user). +- Extract ZIP: require write + scope; consistent 403 messages. +- Save/Rename/Delete/Create: tighten ACL checks; block dangerous extensions; consistent CSRF/Auth handling and error codes. +- Download/ZIP: honor read vs read_own; own-only gates by uploader; safer headers. + +### FolderController.php (v1.5.3) + +- Align with ACL: enforce folder-scope for non-admins; require owner or bypass for destructive ops. +- Create/Rename/Delete: gate by write on parent/target + ownership when needed. +- Share folder link: require share capability; forbid root sharing for non-admins; validate expiry; optional password. +- Folder listing: return only folders user can fully view or has read_own. +- Shared downloads/uploads: stricter validation, headers, and error handling. + +This commits a consistent, least-privilege ACL model (owners/read/write/share/read_own), fixes bulk-select in the UI, and closes scope/ownership gaps across file & folder actions. + +feat(dnd): default cards to sidebar on medium screens when no saved layout + +- Adds one-time responsive default in loadSidebarOrder() (uses layoutDefaultApplied_v1) +- Preserves existing sidebarOrder/headerOrder and small-screen behavior +- Keeps user changes persistent; no override once a layout exists + +--- + ## Changes 10/19/2025 (v1.5.2) fix(admin): modal bugs; chore(api): update ReDoc SRI; docs(openapi): add annotations + spec -feat(dnd): default cards to sidebar on medium screens when no saved layout - adminPanel.js - Fix modal open/close reliability and stacking order @@ -23,10 +60,6 @@ feat(dnd): default cards to sidebar on medium screens when no saved layout common responses, and shared components - Regenerate and commit openapi.json.dist -- Adds one-time responsive default in loadSidebarOrder() (uses layoutDefaultApplied_v1) -- Preserves existing sidebarOrder/headerOrder and small-screen behavior -- Keeps user changes persistent; no override once a layout exists - - public/js/adminPanel.js - public/css/style.css - public/api.php diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index afc72c7..fe29c02 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -4,7 +4,7 @@ import { loadAdminConfigFunc } from './auth.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { sendRequest } from './networkUtils.js'; -const version = "v1.5.2"; +const version = "v1.5.3"; const adminTitle = `${t("admin_panel")} ${version}`; // Translate with fallback: if t(key) just echos the key, use a readable string. diff --git a/public/js/fileListView.js b/public/js/fileListView.js index 1c8a662..79486be 100644 --- a/public/js/fileListView.js +++ b/public/js/fileListView.js @@ -65,6 +65,59 @@ import { return `/api/file/download.php?${q.toString()}`; } + // Wire "select all" header checkbox for the current table render +function wireSelectAll(fileListContent) { + // Be flexible about how the header checkbox is identified + const selectAll = fileListContent.querySelector( + 'thead input[type="checkbox"].select-all, ' + + 'thead .select-all input[type="checkbox"], ' + + 'thead input#selectAll, ' + + 'thead input#selectAllCheckbox, ' + + 'thead input[data-select-all]' + ); + if (!selectAll) return; + + const getRowCbs = () => + Array.from(fileListContent.querySelectorAll('tbody .file-checkbox')) + .filter(cb => !cb.disabled); + + // Toggle all rows when the header checkbox changes + selectAll.addEventListener('change', () => { + const checked = selectAll.checked; + getRowCbs().forEach(cb => { + cb.checked = checked; + updateRowHighlight(cb); + }); + updateFileActionButtons(); + // No indeterminate state when explicitly toggled + selectAll.indeterminate = false; + }); + + // Keep header checkbox state in sync with row selections + const syncHeader = () => { + const cbs = getRowCbs(); + const total = cbs.length; + const checked = cbs.filter(cb => cb.checked).length; + if (!total) { + selectAll.checked = false; + selectAll.indeterminate = false; + return; + } + selectAll.checked = checked === total; + selectAll.indeterminate = checked > 0 && checked < total; + }; + + // Listen for any row checkbox changes to refresh header state + fileListContent.addEventListener('change', (e) => { + if (e.target && e.target.classList.contains('file-checkbox')) { + syncHeader(); + } + }); + + // Initial sync on mount + syncHeader(); +} + /* ----------------------------- Helper: robust JSON handling ----------------------------- */ @@ -607,6 +660,8 @@ import { const bottomControlsHTML = buildBottomControls(itemsPerPageSetting); fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML; + + wireSelectAll(fileListContent); // PATCH each row's preview/thumb to use the secure API URLs if (totalFiles > 0) { @@ -985,6 +1040,7 @@ import { // render fileListContent.innerHTML = galleryHTML; + // pagination buttons for gallery const prevBtn = document.getElementById("prevPageBtn"); diff --git a/src/controllers/FileController.php b/src/controllers/FileController.php index ec307c5..a42f92d 100644 --- a/src/controllers/FileController.php +++ b/src/controllers/FileController.php @@ -65,42 +65,93 @@ class FileController return []; } + /** + * Ownership-only enforcement for a set of files in a folder. + * Returns null if OK, or an error string. + */ 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)); - - 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) { + if (!isset($metadata[$name]['uploader']) || strcasecmp((string)$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 { + /** + * True if the user is an owner of the folder or any ancestor folder (admin also true). + */ + private function ownsFolderOrAncestor(string $folder, string $username, array $userPermissions): bool { + if ($this->isAdmin($userPermissions)) return true; + $folder = ACL::normalizeFolder($folder); + + // Direct folder first, then walk up ancestors (excluding 'root' sentinel) + $f = $folder; + while ($f !== '' && strtolower($f) !== 'root') { + if (ACL::isOwner($username, $userPermissions, $f)) { + return true; + } + $pos = strrpos($f, '/'); + $f = ($pos === false) ? '' : substr($f, 0, $pos); + } + return false; + } + + /** + * Enforce per-folder scope when the account is in "folder-only" mode. + * $need: 'read' (default) | 'write' | 'manage' | 'share' | 'read_own' + * Returns null if allowed, or an error string if forbidden. + */ + private function enforceFolderScope( + string $folder, + string $username, + array $userPermissions, + string $need = 'read' + ): ?string { + // Admins bypass all folder scope checks if ($this->isAdmin($userPermissions)) return null; + + // If the account isn't restricted to a folder scope, don't gate here if (!$this->isFolderOnly($userPermissions)) return null; - $f = trim($folder); + $folder = ACL::normalizeFolder($folder); + + // If user owns this folder (or any ancestor), allow + $f = $folder; while ($f !== '' && strtolower($f) !== 'root') { - if (FolderModel::getOwnerFor($f) === $username) return null; + if (ACL::isOwner($username, $userPermissions, $f)) { + return null; + } $pos = strrpos($f, '/'); - $f = $pos === false ? '' : substr($f, 0, $pos); + $f = ($pos === false) ? '' : substr($f, 0, $pos); } - return "Forbidden: folder scope violation."; + + // Otherwise, require the specific capability on the target folder + $ok = false; + switch ($need) { + case 'manage': + $ok = ACL::canManage($username, $userPermissions, $folder); + break; + case 'write': + $ok = ACL::canWrite($username, $userPermissions, $folder); + break; + case 'share': + $ok = ACL::canShare($username, $userPermissions, $folder); + break; + case 'read_own': + $ok = ACL::canReadOwn($username, $userPermissions, $folder); + break; + default: // 'read' + $ok = ACL::canRead($username, $userPermissions, $folder); + } + + return $ok ? null : "Forbidden: folder scope violation."; } // --- small helpers --- @@ -181,20 +232,33 @@ class FileController $username = $_SESSION['username'] ?? ''; $userPermissions = $this->loadPerms($username); - // ACL: require read on source and write on destination (or write on both if your ACL only has canWrite) - if (!ACL::canRead($username, $userPermissions, $sourceFolder)) { + // Gate: need read on source (or ancestor-owner) and write on destination (or ancestor-owner) + if (!(ACL::canRead($username, $userPermissions, $sourceFolder) || $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions))) { $this->_jsonOut(["error"=>"Forbidden: no read access to source"], 403); return; } - if (!ACL::canWrite($username, $userPermissions, $destinationFolder)) { + if (!(ACL::canWrite($username, $userPermissions, $destinationFolder) || $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions))) { $this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return; } - // scope/ownership - $violation = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions); - if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } - $dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions); + // Folder-scope checks with the needed capabilities + $sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'read'); + if ($sv) { $this->_jsonOut(["error"=>$sv], 403); return; } + + $dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'write'); if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } + // If the user doesn't have full read on source (only read_own), enforce per-file ownership + $ignoreOwnership = $this->isAdmin($userPermissions) + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + if ( + !$ignoreOwnership + && !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read + && ACL::hasGrant($username, $sourceFolder, 'read_own') // but has own-only + ) { + $ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions); + if ($ownErr) { $this->_jsonOut(["error"=>$ownErr], 403); return; } + } + $result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files); $this->_jsonOut($result); } catch (Throwable $e) { @@ -223,12 +287,24 @@ class FileController $username = $_SESSION['username'] ?? ''; $userPermissions = $this->loadPerms($username); - if (!ACL::canWrite($username, $userPermissions, $folder)) { + // Need write (or ancestor-owner) + if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) { $this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return; } - $violation = $this->enforceScopeAndOwnership($folder, $data['files'], $username, $userPermissions); - if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } + // Folder-scope: write + $dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write'); + if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } + + // Ownership enforcement for non-admins who are not folder owners + $ignoreOwnership = $this->isAdmin($userPermissions) + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + $isFolderOwner = ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions); + + if (!$ignoreOwnership && !$isFolderOwner) { + $violation = $this->enforceScopeAndOwnership($folder, $data['files'], $username, $userPermissions); + if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } + } $result = FileModel::deleteFiles($folder, $data['files']); $this->_jsonOut($result); @@ -259,20 +335,36 @@ class FileController $username = $_SESSION['username'] ?? ''; $userPermissions = $this->loadPerms($username); - // Require write on both source and destination to be safe - if (!ACL::canWrite($username, $userPermissions, $sourceFolder)) { + // Require write on both ends (or ancestor-owner on each end) + if (!(ACL::canWrite($username, $userPermissions, $sourceFolder) || $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions))) { $this->_jsonOut(["error"=>"Forbidden: no write access to source"], 403); return; } - if (!ACL::canWrite($username, $userPermissions, $destinationFolder)) { + if (!(ACL::canWrite($username, $userPermissions, $destinationFolder) || $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions))) { $this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return; } - $violation = $this->enforceScopeAndOwnership($sourceFolder, $data['files'], $username, $userPermissions); - if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } - $dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions); + $files = $data['files']; + + // Folder scope: need WRITE on both ends for a move + $sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'write'); + if ($sv) { $this->_jsonOut(["error"=>$sv], 403); return; } + $dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'write'); if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } - $result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['files']); + // If the user doesn't have full read on source (only read_own), enforce per-file ownership + $ignoreOwnership = $this->isAdmin($userPermissions) + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + + if ( + !$ignoreOwnership + && !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read + && ACL::hasGrant($username, $sourceFolder, 'read_own') // but has own-only + ) { + $ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions); + if ($ownErr) { $this->_jsonOut(["error"=>$ownErr], 403); return; } + } + + $result = FileModel::moveFiles($sourceFolder, $destinationFolder, $files); $this->_jsonOut($result); } catch (Throwable $e) { error_log('FileController::moveFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); @@ -303,12 +395,23 @@ class FileController $username = $_SESSION['username'] ?? ''; $userPermissions = $this->loadPerms($username); - if (!ACL::canWrite($username, $userPermissions, $folder)) { + // Need write (or ancestor-owner) + if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) { $this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return; } - $violation = $this->enforceScopeAndOwnership($folder, [$oldName], $username, $userPermissions); - if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } + // Folder scope: write + $dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write'); + if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } + + // Ownership for non-admins when not a folder owner + $ignoreOwnership = $this->isAdmin($userPermissions) + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + $isFolderOwner = ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions); + if (!$ignoreOwnership && !$isFolderOwner) { + $violation = $this->enforceScopeAndOwnership($folder, [$oldName], $username, $userPermissions); + if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } + } $result = FileModel::renameFile($folder, $oldName, $newName); if (!is_array($result)) throw new RuntimeException('FileModel::renameFile returned non-array'); @@ -340,20 +443,29 @@ class FileController $username = $_SESSION['username'] ?? ''; $userPermissions = $this->loadPerms($username); - if (!ACL::canWrite($username, $userPermissions, $folder)) { + // Need write (or ancestor-owner) + if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) { $this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return; } - $dv = $this->enforceFolderScope($folder, $username, $userPermissions); + // Folder scope: write + $dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write'); if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } - // If overwriting, enforce ownership for non-admins + // If overwriting, enforce ownership for non-admins (unless folder owner) $baseDir = rtrim(UPLOAD_DIR, '/\\'); $dir = ($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; } + $ignoreOwnership = $this->isAdmin($userPermissions) + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)) + || ACL::isOwner($username, $userPermissions, $folder) + || $this->ownsFolderOrAncestor($folder, $username, $userPermissions); + + if (!$ignoreOwnership) { + $violation = $this->enforceScopeAndOwnership($folder, [$fileName], $username, $userPermissions); + if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } + } } $deny = ['php','phtml','phar','php3','php4','php5','php7','php8','pht','shtml','cgi','fcgi']; @@ -374,180 +486,187 @@ class FileController } public function downloadFile() -{ - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - header('Content-Type: application/json'); - echo json_encode(["error" => "Unauthorized"]); - exit; - } - - $file = isset($_GET['file']) ? basename($_GET['file']) : ''; - $folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; - - if (!preg_match(REGEX_FILE_NAME, $file)) { - http_response_code(400); - echo json_encode(["error" => "Invalid file name."]); - exit; - } - if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { - http_response_code(400); - echo json_encode(["error" => "Invalid folder name."]); - exit; - } - - $username = $_SESSION['username'] ?? ''; - $perms = $this->loadPerms($username); - - $ignoreOwnership = $this->isAdmin($perms) - || ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); - - // Folder-level view grants - $fullView = $ignoreOwnership || ACL::canRead($username, $perms, $folder); - $ownGrant = !$fullView && ACL::hasGrant($username, $folder, 'read_own'); - - if (!$fullView && !$ownGrant) { - http_response_code(403); - echo json_encode(["error" => "Forbidden: no view access to this folder."]); - exit; - } - - // If own-only, enforce uploader==user - if ($ownGrant) { - $meta = $this->loadFolderMetadata($folder); - if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) { - http_response_code(403); - echo json_encode(["error" => "Forbidden: you are not the owner of this file."]); + { + if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(["error" => "Unauthorized"]); exit; } - } - $downloadInfo = FileModel::getDownloadInfo($folder, $file); - if (isset($downloadInfo['error'])) { - http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400); - echo json_encode(["error" => $downloadInfo['error']]); - exit; - } + $file = isset($_GET['file']) ? basename($_GET['file']) : ''; + $folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; - $realFilePath = $downloadInfo['filePath']; - $mimeType = $downloadInfo['mimeType']; - header("Content-Type: " . $mimeType); - - $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); - $inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico']; - if (in_array($ext, $inlineImageTypes, true)) { - header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"'); - } else { - header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"'); - } - header('Content-Length: ' . filesize($realFilePath)); - readfile($realFilePath); - exit; -} - -public function downloadZip() -{ - $this->_jsonStart(); - try { - if (!$this->_checkCsrf()) return; - if (!$this->_requireAuth()) return; - - $data = $this->_readJsonBody(); - if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) { - $this->_jsonOut(["error" => "Invalid input."], 400); return; + if (!preg_match(REGEX_FILE_NAME, $file)) { + http_response_code(400); + echo json_encode(["error" => "Invalid file name."]); + exit; + } + if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + http_response_code(400); + echo json_encode(["error" => "Invalid folder name."]); + exit; } - - $folder = $this->_normalizeFolder($data['folder']); - $files = $data['files']; - if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; } $username = $_SESSION['username'] ?? ''; $perms = $this->loadPerms($username); - // Optional zip gate by account flag - if (!$this->isAdmin($perms) && array_key_exists('canZip', $perms) && !$perms['canZip']) { - $this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403); return; - } - $ignoreOwnership = $this->isAdmin($perms) || ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); - $fullView = $ignoreOwnership || ACL::canRead($username, $perms, $folder); - $ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own'); + // Treat ancestor-folder ownership as full view as well + $fullView = $ignoreOwnership + || ACL::canRead($username, $perms, $folder) + || $this->ownsFolderOrAncestor($folder, $username, $perms); - if (!$fullView && !$ownOnly) { - $this->_jsonOut(["error" => "Forbidden: no view access to this folder."], 403); return; + $ownGrant = !$fullView && ACL::hasGrant($username, $folder, 'read_own'); + + if (!$fullView && !$ownGrant) { + http_response_code(403); + echo json_encode(["error" => "Forbidden: no view access to this folder."]); + exit; } - // If own-only, ensure all files are owned by the user - if ($ownOnly) { + // If own-only, enforce uploader==user + if ($ownGrant) { $meta = $this->loadFolderMetadata($folder); - foreach ($files as $f) { - $bn = basename((string)$f); - if (!isset($meta[$bn]['uploader']) || strcasecmp((string)$meta[$bn]['uploader'], $username) !== 0) { - $this->_jsonOut(["error" => "Forbidden: you are not the owner of '{$bn}'."], 403); return; - } + if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) { + http_response_code(403); + echo json_encode(["error" => "Forbidden: you are not the owner of this file."]); + exit; } } - $result = FileModel::createZipArchive($folder, $files); - if (isset($result['error'])) { - $this->_jsonOut(["error" => $result['error']], 400); return; + $downloadInfo = FileModel::getDownloadInfo($folder, $file); + if (isset($downloadInfo['error'])) { + http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400); + echo json_encode(["error" => $downloadInfo['error']]); + exit; } - $zipPath = $result['zipPath'] ?? null; - if (!$zipPath || !file_exists($zipPath)) { $this->_jsonOut(["error"=>"ZIP archive not found."], 500); return; } + $realFilePath = $downloadInfo['filePath']; + $mimeType = $downloadInfo['mimeType']; + header("Content-Type: " . $mimeType); - // switch to file streaming - header_remove('Content-Type'); - header('Content-Type: application/zip'); - header('Content-Disposition: attachment; filename="files.zip"'); - header('Content-Length: ' . filesize($zipPath)); - header('Cache-Control: no-store, no-cache, must-revalidate'); - header('Pragma: no-cache'); - - readfile($zipPath); - @unlink($zipPath); + $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); + $inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico']; + if (in_array($ext, $inlineImageTypes, true)) { + header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"'); + } else { + header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"'); + } + header('Content-Length: ' . filesize($realFilePath)); + readfile($realFilePath); exit; - } catch (Throwable $e) { - error_log('FileController::downloadZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); - $this->_jsonOut(['error' => 'Internal server error while preparing ZIP.'], 500); - } finally { $this->_jsonEnd(); } -} + } -public function extractZip() -{ - $this->_jsonStart(); - try { - if (!$this->_checkCsrf()) return; - if (!$this->_requireAuth()) return; + public function downloadZip() + { + $this->_jsonStart(); + try { + if (!$this->_checkCsrf()) return; + if (!$this->_requireAuth()) return; - $data = $this->_readJsonBody(); - if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) { - $this->_jsonOut(["error" => "Invalid input."], 400); return; - } + $data = $this->_readJsonBody(); + if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) { + $this->_jsonOut(["error" => "Invalid input."], 400); return; + } - $folder = $this->_normalizeFolder($data['folder']); - if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; } + $folder = $this->_normalizeFolder($data['folder']); + $files = $data['files']; + if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; } - $username = $_SESSION['username'] ?? ''; - $perms = $this->loadPerms($username); + $username = $_SESSION['username'] ?? ''; + $perms = $this->loadPerms($username); - // must be able to write into target folder - if (!ACL::canWrite($username, $perms, $folder)) { - $this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return; - } + // Optional zip gate by account flag + if (!$this->isAdmin($perms) && array_key_exists('canZip', $perms) && !$perms['canZip']) { + $this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403); return; + } - $dv = $this->enforceFolderScope($folder, $username, $perms); - if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } + $ignoreOwnership = $this->isAdmin($perms) + || ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); - $result = FileModel::extractZipArchive($folder, $data['files']); - $this->_jsonOut($result); - } catch (Throwable $e) { - error_log('FileController::extractZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); - $this->_jsonOut(['error' => 'Internal server error while extracting ZIP.'], 500); - } finally { $this->_jsonEnd(); } -} + // Ancestor-owner counts as full view + $fullView = $ignoreOwnership + || ACL::canRead($username, $perms, $folder) + || $this->ownsFolderOrAncestor($folder, $username, $perms); + $ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own'); + + if (!$fullView && !$ownOnly) { + $this->_jsonOut(["error" => "Forbidden: no view access to this folder."], 403); return; + } + + // If own-only, ensure all files are owned by the user + if ($ownOnly) { + $meta = $this->loadFolderMetadata($folder); + foreach ($files as $f) { + $bn = basename((string)$f); + if (!isset($meta[$bn]['uploader']) || strcasecmp((string)$meta[$bn]['uploader'], $username) !== 0) { + $this->_jsonOut(["error" => "Forbidden: you are not the owner of '{$bn}'."], 403); return; + } + } + } + + $result = FileModel::createZipArchive($folder, $files); + if (isset($result['error'])) { + $this->_jsonOut(["error" => $result['error']], 400); return; + } + + $zipPath = $result['zipPath'] ?? null; + if (!$zipPath || !file_exists($zipPath)) { $this->_jsonOut(["error"=>"ZIP archive not found."], 500); return; } + + // switch to file streaming + header_remove('Content-Type'); + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="files.zip"'); + header('Content-Length: ' . filesize($zipPath)); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + + readfile($zipPath); + @unlink($zipPath); + exit; + } catch (Throwable $e) { + error_log('FileController::downloadZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); + $this->_jsonOut(['error' => 'Internal server error while preparing ZIP.'], 500); + } finally { $this->_jsonEnd(); } + } + + public function extractZip() + { + $this->_jsonStart(); + try { + if (!$this->_checkCsrf()) return; + if (!$this->_requireAuth()) return; + + $data = $this->_readJsonBody(); + if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) { + $this->_jsonOut(["error" => "Invalid input."], 400); return; + } + + $folder = $this->_normalizeFolder($data['folder']); + if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; } + + $username = $_SESSION['username'] ?? ''; + $perms = $this->loadPerms($username); + + // must be able to write into target folder (or be ancestor-owner) + if (!(ACL::canWrite($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms))) { + $this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return; + } + + // Folder scope: write + $dv = $this->enforceFolderScope($folder, $username, $perms, 'write'); + if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } + + $result = FileModel::extractZipArchive($folder, $data['files']); + $this->_jsonOut($result); + } catch (Throwable $e) { + error_log('FileController::extractZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); + $this->_jsonOut(['error' => 'Internal server error while extracting ZIP.'], 500); + } finally { $this->_jsonEnd(); } + } public function shareFile() { @@ -665,15 +784,24 @@ public function extractZip() $username = $_SESSION['username'] ?? ''; $userPermissions = $this->loadPerms($username); - if (!ACL::canShare($username, $userPermissions, $folder)) { + // Need share (or ancestor-owner) + if (!(ACL::canShare($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) { $this->_jsonOut(["error"=>"Forbidden: no share access"], 403); return; } + // Folder scope: share + $sv = $this->enforceFolderScope($folder, $username, $userPermissions, 'share'); + if ($sv) { $this->_jsonOut(["error"=>$sv], 403); return; } + + // Ownership unless admin/folder-owner $ignoreOwnership = $this->isAdmin($userPermissions) - || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)) + || ACL::isOwner($username, $userPermissions, $folder) + || $this->ownsFolderOrAncestor($folder, $username, $userPermissions); + if (!$ignoreOwnership) { $meta = $this->loadFolderMetadata($folder); - if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) { + if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) { $this->_jsonOut(["error" => "Forbidden: you are not the owner of this file."], 403); return; } } @@ -695,86 +823,86 @@ public function extractZip() } public function getTrashItems() -{ - $this->_jsonStart(); - try { - if (!$this->_requireAuth()) return; - $perms = $this->loadPerms($_SESSION['username'] ?? ''); - if (!$this->isAdmin($perms)) { $this->_jsonOut(['error'=>'Admin only'], 403); return; } + { + $this->_jsonStart(); + try { + if (!$this->_requireAuth()) return; + $perms = $this->loadPerms($_SESSION['username'] ?? ''); + if (!$this->isAdmin($perms)) { $this->_jsonOut(['error'=>'Admin only'], 403); return; } - $trashItems = FileModel::getTrashItems(); - $this->_jsonOut($trashItems); - } catch (Throwable $e) { - error_log('FileController::getTrashItems error: '.$e->getMessage()); - $this->_jsonOut(['error' => 'Internal server error while fetching trash.'], 500); - } finally { $this->_jsonEnd(); } -} + $trashItems = FileModel::getTrashItems(); + $this->_jsonOut($trashItems); + } catch (Throwable $e) { + error_log('FileController::getTrashItems error: '.$e->getMessage()); + $this->_jsonOut(['error' => 'Internal server error while fetching trash.'], 500); + } finally { $this->_jsonEnd(); } + } -public function restoreFiles() -{ - $this->_jsonStart(); - try { - if (!$this->_checkCsrf()) return; - if (!$this->_requireAuth()) return; - $perms = $this->loadPerms($_SESSION['username'] ?? ''); - if (!$this->isAdmin($perms)) { $this->_jsonOut(['error'=>'Admin only'], 403); return; } + public function restoreFiles() + { + $this->_jsonStart(); + try { + if (!$this->_checkCsrf()) return; + if (!$this->_requireAuth()) return; + $perms = $this->loadPerms($_SESSION['username'] ?? ''); + if (!$this->isAdmin($perms)) { $this->_jsonOut(['error'=>'Admin only'], 403); return; } - $data = $this->_readJsonBody(); - if (!isset($data['files']) || !is_array($data['files'])) { - $this->_jsonOut(["error" => "No file or folder identifiers provided"], 400); return; - } - $result = FileModel::restoreFiles($data['files']); - $this->_jsonOut($result); - } catch (Throwable $e) { - error_log('FileController::restoreFiles error: '.$e->getMessage()); - $this->_jsonOut(['error' => 'Internal server error while restoring files.'], 500); - } finally { $this->_jsonEnd(); } -} + $data = $this->_readJsonBody(); + if (!isset($data['files']) || !is_array($data['files'])) { + $this->_jsonOut(["error" => "No file or folder identifiers provided"], 400); return; + } + $result = FileModel::restoreFiles($data['files']); + $this->_jsonOut($result); + } catch (Throwable $e) { + error_log('FileController::restoreFiles error: '.$e->getMessage()); + $this->_jsonOut(['error' => 'Internal server error while restoring files.'], 500); + } finally { $this->_jsonEnd(); } + } -public function deleteTrashFiles() -{ - $this->_jsonStart(); - try { - if (!$this->_checkCsrf()) return; - if (!$this->_requireAuth()) return; - $perms = $this->loadPerms($_SESSION['username'] ?? ''); - if (!$this->isAdmin($perms)) { $this->_jsonOut(['error'=>'Admin only'], 403); return; } + public function deleteTrashFiles() + { + $this->_jsonStart(); + try { + if (!$this->_checkCsrf()) return; + if (!$this->_requireAuth()) return; + $perms = $this->loadPerms($_SESSION['username'] ?? ''); + if (!$this->isAdmin($perms)) { $this->_jsonOut(['error'=>'Admin only'], 403); return; } - $data = $this->_readJsonBody(); - if (!$data) { $this->_jsonOut(["error" => "Invalid input"], 400); return; } + $data = $this->_readJsonBody(); + if (!$data) { $this->_jsonOut(["error" => "Invalid input"], 400); return; } - $filesToDelete = []; - if (!empty($data['deleteAll'])) { - $trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; - $shareFile = $trashDir . "trash.json"; - if (file_exists($shareFile)) { - $tmp = json_decode(file_get_contents($shareFile), true); - if (is_array($tmp)) { - foreach ($tmp as $item) { - if (!empty($item['trashName'])) $filesToDelete[] = $item['trashName']; + $filesToDelete = []; + if (!empty($data['deleteAll'])) { + $trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; + $shareFile = $trashDir . "trash.json"; + if (file_exists($shareFile)) { + $tmp = json_decode(file_get_contents($shareFile), true); + if (is_array($tmp)) { + foreach ($tmp as $item) { + if (!empty($item['trashName'])) $filesToDelete[] = $item['trashName']; + } } } + } elseif (isset($data['files']) && is_array($data['files'])) { + $filesToDelete = $data['files']; + } else { + $this->_jsonOut(["error" => "No trash file identifiers provided"], 400); return; } - } elseif (isset($data['files']) && is_array($data['files'])) { - $filesToDelete = $data['files']; - } else { - $this->_jsonOut(["error" => "No trash file identifiers provided"], 400); return; - } - $result = FileModel::deleteTrashFiles($filesToDelete); - if (!empty($result['deleted'])) { - $msg = "Trash item".(count($result['deleted']) === 1 ? "" : "s")." deleted: ".implode(", ", $result['deleted']); - $this->_jsonOut(["success"=>$msg]); - } elseif (!empty($result['error'])) { - $this->_jsonOut(["error"=>$result['error']], 400); - } else { - $this->_jsonOut(["success"=>"No items to delete."]); - } - } catch (Throwable $e) { - error_log('FileController::deleteTrashFiles error: '.$e->getMessage()); - $this->_jsonOut(['error' => 'Internal server error while deleting trash files.'], 500); - } finally { $this->_jsonEnd(); } -} + $result = FileModel::deleteTrashFiles($filesToDelete); + if (!empty($result['deleted'])) { + $msg = "Trash item".(count($result['deleted']) === 1 ? "" : "s")." deleted: ".implode(", ", $result['deleted']); + $this->_jsonOut(["success"=>$msg]); + } elseif (!empty($result['error'])) { + $this->_jsonOut(["error"=>$result['error']], 400); + } else { + $this->_jsonOut(["success"=>"No items to delete."]); + } + } catch (Throwable $e) { + error_log('FileController::deleteTrashFiles error: '.$e->getMessage()); + $this->_jsonOut(['error' => 'Internal server error while deleting trash files.'], 500); + } finally { $this->_jsonEnd(); } + } public function getFileTags(): void { @@ -806,15 +934,23 @@ public function deleteTrashFiles() $username = $_SESSION['username'] ?? ''; $userPermissions = $this->loadPerms($username); - if (!ACL::canWrite($username, $userPermissions, $folder)) { + // Need write (or ancestor-owner) + if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) { $this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return; } + // Folder scope: write + $dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write'); + if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } + + // Ownership unless admin/folder-owner $ignoreOwnership = $this->isAdmin($userPermissions) - || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)) + || ACL::isOwner($username, $userPermissions, $folder) + || $this->ownsFolderOrAncestor($folder, $username, $userPermissions); if (!$ignoreOwnership) { $meta = $this->loadFolderMetadata($folder); - if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) { + if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) { $this->_jsonOut(["error" => "Forbidden: you are not the owner of this file."], 403); return; } } @@ -828,101 +964,102 @@ public function deleteTrashFiles() } public function getFileList(): void -{ - if (session_status() !== PHP_SESSION_ACTIVE) session_start(); - header('Content-Type: application/json; charset=utf-8'); + { + if (session_status() !== PHP_SESSION_ACTIVE) session_start(); + header('Content-Type: application/json; charset=utf-8'); - // convert warnings/notices to exceptions for cleaner error handling - set_error_handler(function ($severity, $message, $file, $line) { - if (!(error_reporting() & $severity)) return; - throw new ErrorException($message, 0, $severity, $file, $line); - }); + // convert warnings/notices to exceptions for cleaner error handling + 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']); - return; - } + try { + if (empty($_SESSION['username'])) { + http_response_code(401); + echo json_encode(['error' => 'Unauthorized']); + return; + } - if (!is_dir(META_DIR)) @mkdir(META_DIR, 0775, true); + 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.']); - return; - } + $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.']); + return; + } - if (!is_dir(UPLOAD_DIR)) { - http_response_code(500); - echo json_encode(['error' => 'Uploads directory not found.']); - return; - } + if (!is_dir(UPLOAD_DIR)) { + http_response_code(500); + echo json_encode(['error' => 'Uploads directory not found.']); + return; + } - // ---- Folder-level view checks (full vs own-only) ---- - $username = $_SESSION['username'] ?? ''; - $perms = $this->loadPerms($username); // your existing helper - $fullView = ACL::canRead($username, $perms, $folder); - $ownOnlyGrant = ACL::hasGrant($username, $folder, 'read_own'); + // ---- Folder-level view checks (full vs own-only) ---- + $username = $_SESSION['username'] ?? ''; + $perms = $this->loadPerms($username); - if (!$fullView && !$ownOnlyGrant) { - http_response_code(403); - echo json_encode(['error' => 'Forbidden: no view access to this folder.']); - return; - } + // Full view if read OR ancestor owner + $fullView = ACL::canRead($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms); + $ownOnlyGrant = ACL::hasGrant($username, $folder, 'read_own'); - // Fetch the list - $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; - } + if (!$fullView && !$ownOnlyGrant) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden: no view access to this folder.']); + return; + } - // ---- Apply own-only filter if user does NOT have full view ---- - if (!$fullView && $ownOnlyGrant && isset($result['files'])) { - $files = $result['files']; + // Fetch the list + $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; + } - // If files keyed by filename - if (is_array($files) && array_keys($files) !== range(0, count($files) - 1)) { - $filtered = []; - foreach ($files as $name => $meta) { - // SAFETY: only include when uploader is present AND matches - if (isset($meta['uploader']) && strcasecmp((string)$meta['uploader'], $username) === 0) { - $filtered[$name] = $meta; + // ---- Apply own-only filter if user does NOT have full view ---- + if (!$fullView && $ownOnlyGrant && isset($result['files'])) { + $files = $result['files']; + + // If files keyed by filename + if (is_array($files) && array_keys($files) !== range(0, count($files) - 1)) { + $filtered = []; + foreach ($files as $name => $meta) { + if (isset($meta['uploader']) && strcasecmp((string)$meta['uploader'], $username) === 0) { + $filtered[$name] = $meta; + } } + $result['files'] = $filtered; + } + // If files are a numeric array of metadata + else if (is_array($files)) { + $result['files'] = array_values(array_filter( + $files, + function ($f) use ($username) { + return isset($f['uploader']) && strcasecmp((string)$f['uploader'], $username) === 0; + } + )); } - $result['files'] = $filtered; } - // If files are a numeric array of metadata - else if (is_array($files)) { - $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); - } 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(); + echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } 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(); + } } -} public function getShareLinks() { @@ -979,11 +1116,13 @@ public function deleteTrashFiles() $username = $_SESSION['username'] ?? ''; $userPermissions = $this->loadPerms($username); - if (!ACL::canWrite($username, $userPermissions, $folder)) { + // Need write (or ancestor-owner) + if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) { $this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return; } - $dv = $this->enforceFolderScope($folder, $username, $userPermissions); + // Folder scope: write + $dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write'); if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } $result = FileModel::createFile($folder, $filename, $username); diff --git a/src/controllers/FolderController.php b/src/controllers/FolderController.php index 8e2bd7d..b006c41 100644 --- a/src/controllers/FolderController.php +++ b/src/controllers/FolderController.php @@ -96,6 +96,11 @@ class FolderController return false; } + private static function isFolderOnly(array $perms): bool + { + return !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']); + } + private static function requireNotReadOnly(): void { $perms = self::getPerms(); @@ -126,23 +131,52 @@ class FolderController return round($bytes / 1073741824, 2) . " GB"; } - /** Enforce "user folder only" scope for non-admins. Returns error string or null if allowed. */ - private static function enforceFolderScope(string $folder, string $username, array $perms): ?string + /** Return true if user is explicit owner of the folder or any of its ancestors (admins also true). */ + private static function ownsFolderOrAncestor(string $folder, string $username, array $perms): bool { + if (self::isAdmin($perms)) return true; + $folder = ACL::normalizeFolder($folder); + $f = $folder; + while ($f !== '' && strtolower($f) !== 'root') { + if (ACL::isOwner($username, $perms, $f)) return true; + $pos = strrpos($f, '/'); + $f = ($pos === false) ? '' : substr($f, 0, $pos); + } + return false; + } + + /** + * Enforce per-folder scope for folder-only accounts. + * $need: 'read' | 'write' | 'manage' | 'share' | 'read_own' (default 'read') + * Returns null if allowed, or an error string if forbidden. + */ + private static function enforceFolderScope(string $folder, string $username, array $perms, string $need = 'read'): ?string + { + // Admins bypass scope if (self::isAdmin($perms)) return null; - $folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']); - if (!$folderOnly) return null; + // Not a folder-only account? no gate here + if (!self::isFolderOnly($perms)) return null; - $folder = trim($folder); - if ($folder === '' || strcasecmp($folder, 'root') === 0) { - return "Forbidden: non-admins may not operate on the root folder."; + $folder = ACL::normalizeFolder($folder); + + // If user owns folder or an ancestor, allow + $f = $folder; + while ($f !== '' && strtolower($f) !== 'root') { + if (ACL::isOwner($username, $perms, $f)) return null; + $pos = strrpos($f, '/'); + $f = ($pos === false) ? '' : substr($f, 0, $pos); } - if ($folder === $username || strpos($folder, $username . '/') === 0) { - return null; + // Otherwise, require specific capability on the target folder + switch ($need) { + case 'manage': $ok = ACL::canManage($username, $perms, $folder); break; + case 'write': $ok = ACL::canWrite($username, $perms, $folder); break; + case 'share': $ok = ACL::canShare($username, $perms, $folder); break; + case 'read_own': $ok = ACL::canReadOwn($username, $perms, $folder);break; + default: $ok = ACL::canRead($username, $perms, $folder); } - return "Forbidden: folder scope violation."; + return $ok ? null : "Forbidden: folder scope violation."; } /** Returns true if caller can ignore ownership (admin or bypassOwnership/default). */ @@ -152,62 +186,58 @@ class FolderController return (bool)($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); } - /** Returns true if caller can share. */ - private static function canShare(array $perms): bool + /** ACL-aware folder owner check (explicit). */ + private static function isFolderOwner(string $folder, string $username, array $perms): bool { - if (self::isAdmin($perms)) return true; - return (bool)($perms['canShare'] ?? (defined('DEFAULT_CAN_SHARE') ? DEFAULT_CAN_SHARE : false)); - } - - /** Check folder ownership via mapping; returns true if $username is the explicit owner. */ - private static function isFolderOwner(string $folder, string $username): bool - { - $owner = FolderModel::getOwnerFor($folder); - return is_string($owner) && strcasecmp($owner, $username) === 0; + return ACL::isOwner($username, $perms, $folder); } /* -------------------- API: Create Folder -------------------- */ public function createFolder(): void -{ - header('Content-Type: application/json'); - self::requireAuth(); - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit; } - self::requireCsrf(); - self::requireNotReadOnly(); + { + header('Content-Type: application/json'); + self::requireAuth(); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit; } + self::requireCsrf(); + self::requireNotReadOnly(); - $input = json_decode(file_get_contents('php://input'), true); - if (!isset($input['folderName'])) { http_response_code(400); echo json_encode(['error' => 'Folder name not provided.']); exit; } + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['folderName'])) { http_response_code(400); echo json_encode(['error' => 'Folder name not provided.']); exit; } - $folderName = trim((string)$input['folderName']); - $parentIn = isset($input['parent']) ? trim((string)$input['parent']) : ''; + $folderName = trim((string)$input['folderName']); + $parentIn = isset($input['parent']) ? trim((string)$input['parent']) : ''; - if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { - http_response_code(400); echo json_encode(['error' => 'Invalid folder name.']); exit; - } - if ($parentIn !== '' && strcasecmp($parentIn, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parentIn)) { - http_response_code(400); echo json_encode(['error' => 'Invalid parent folder name.']); exit; - } + if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { + http_response_code(400); echo json_encode(['error' => 'Invalid folder name.']); exit; + } + if ($parentIn !== '' && strcasecmp($parentIn, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parentIn)) { + http_response_code(400); echo json_encode(['error' => 'Invalid parent folder name.']); exit; + } - // Normalize parent to an ACL key - $parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn; + // Normalize parent to an ACL key + $parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn; - $username = $_SESSION['username'] ?? ''; - $perms = self::getPerms(); + $username = $_SESSION['username'] ?? ''; + $perms = self::getPerms(); - // ACL: must be able to WRITE into the parent folder (admins pass) - if (!self::isAdmin($perms) && !ACL::canWrite($username, $perms, $parent)) { - http_response_code(403); - echo json_encode(['error' => 'Forbidden: no write access to parent folder.']); + // Must be able to write into parent OR be owner (or ancestor owner) of it + if (!(ACL::canWrite($username, $perms, $parent) || self::ownsFolderOrAncestor($parent, $username, $perms))) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden: no write access to parent folder.']); + exit; + } + + // Folder-scope gate for folder-only accounts (need write on parent) + if ($msg = self::enforceFolderScope($parent, $username, $perms, 'write')) { + http_response_code(403); echo json_encode(['error' => $msg]); exit; + } + + // Model should create folder and seed ACL (owner = creator) + $result = FolderModel::createFolder($folderName, $parent, $username); + echo json_encode($result); exit; } - // Let the model do the filesystem work AND seed ACL owner - $result = FolderModel::createFolder($folderName, $parent, $username); - - echo json_encode($result); - exit; -} - /* -------------------- API: Delete Folder -------------------- */ public function deleteFolder(): void { @@ -220,15 +250,26 @@ class FolderController $input = json_decode(file_get_contents('php://input'), true); if (!isset($input['folder'])) { http_response_code(400); echo json_encode(["error" => "Folder name not provided."]); exit; } - $folder = trim($input['folder']); + $folder = trim((string)$input['folder']); if (strcasecmp($folder, 'root') === 0) { http_response_code(400); echo json_encode(["error" => "Cannot delete root folder."]); exit; } if (!preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; } $username = $_SESSION['username'] ?? ''; $perms = self::getPerms(); - if ($msg = self::enforceFolderScope($folder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => $msg]); exit; } - if (!self::canBypassOwnership($perms) && !self::isFolderOwner($folder, $username)) { + // Folder-scope: need manage (owner) OR explicit manage grant + if ($msg = self::enforceFolderScope($folder, $username, $perms, 'manage')) { + http_response_code(403); echo json_encode(["error" => $msg]); exit; + } + + // Require either manage permission or ancestor ownership (strong gate) + $canManage = ACL::canManage($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms); + if (!$canManage) { + http_response_code(403); echo json_encode(["error" => "Forbidden: you lack manage rights for this folder."]); exit; + } + + // If not bypassing ownership, require ownership (direct or ancestor) as an extra safeguard + if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the folder owner."]); exit; } @@ -251,8 +292,8 @@ class FolderController http_response_code(400); echo json_encode(['error' => 'Required folder names not provided.']); exit; } - $oldFolder = trim($input['oldFolder']); - $newFolder = trim($input['newFolder']); + $oldFolder = trim((string)$input['oldFolder']); + $newFolder = trim((string)$input['newFolder']); if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) { http_response_code(400); echo json_encode(['error' => 'Invalid folder name(s).']); exit; @@ -261,10 +302,23 @@ class FolderController $username = $_SESSION['username'] ?? ''; $perms = self::getPerms(); - if ($msg = self::enforceFolderScope($oldFolder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => $msg]); exit; } - if ($msg = self::enforceFolderScope($newFolder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => "New path not allowed: " . $msg]); exit; } + // Must be allowed to manage the old folder + if ($msg = self::enforceFolderScope($oldFolder, $username, $perms, 'manage')) { + http_response_code(403); echo json_encode(["error" => $msg]); exit; + } + // For the new folder path, require write scope (we're "creating" a path) + if ($msg = self::enforceFolderScope($newFolder, $username, $perms, 'write')) { + http_response_code(403); echo json_encode(["error" => "New path not allowed: " . $msg]); exit; + } - if (!self::canBypassOwnership($perms) && !self::isFolderOwner($oldFolder, $username)) { + // Strong gates: need manage on old OR ancestor owner; need write on new parent or ancestor owner + $canManageOld = ACL::canManage($username, $perms, $oldFolder) || self::ownsFolderOrAncestor($oldFolder, $username, $perms); + if (!$canManageOld) { + http_response_code(403); echo json_encode(['error' => 'Forbidden: you lack manage rights on the source folder.']); exit; + } + + // If not bypassing ownership, require ownership (direct or ancestor) on the old folder + if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($oldFolder, $username, $perms)) { http_response_code(403); echo json_encode(['error' => 'Forbidden: you are not the folder owner.']); exit; } @@ -275,66 +329,64 @@ class FolderController /* -------------------- API: Get Folder List -------------------- */ public function getFolderList(): void -{ - header('Content-Type: application/json'); - self::requireAuth(); + { + header('Content-Type: application/json'); + self::requireAuth(); - // Optional "folder" filter (supports nested like "team/reports") - $parent = $_GET['folder'] ?? null; - if ($parent !== null && $parent !== '' && strcasecmp($parent, 'root') !== 0) { - $parts = array_filter(explode('/', trim($parent, "/\\ ")), fn($p) => $p !== ''); - if (empty($parts)) { - http_response_code(400); - echo json_encode(["error" => "Invalid folder name."]); - exit; - } - foreach ($parts as $seg) { - if (!preg_match(REGEX_FOLDER_NAME, $seg)) { + // Optional "folder" filter (supports nested like "team/reports") + $parent = $_GET['folder'] ?? null; + if ($parent !== null && $parent !== '' && strcasecmp($parent, 'root') !== 0) { + $parts = array_filter(explode('/', trim($parent, "/\\ ")), fn($p) => $p !== ''); + if (empty($parts)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; } + foreach ($parts as $seg) { + if (!preg_match(REGEX_FOLDER_NAME, $seg)) { + http_response_code(400); + echo json_encode(["error" => "Invalid folder name."]); + exit; + } + } + $parent = implode('/', $parts); } - $parent = implode('/', $parts); - } - $username = $_SESSION['username'] ?? ''; - $perms = loadUserPermissions($username) ?: []; - $isAdmin = self::isAdmin($perms); + $username = $_SESSION['username'] ?? ''; + $perms = self::getPerms(); + $isAdmin = self::isAdmin($perms); - // 1) full list from model - $all = FolderModel::getFolderList(); // each row: ["folder","fileCount","metadataFile"] - if (!is_array($all)) { - echo json_encode([]); + // 1) Full list from model + $all = FolderModel::getFolderList(); // each row: ["folder","fileCount","metadataFile"] + if (!is_array($all)) { echo json_encode([]); exit; } + + // 2) Filter by view rights + if (!$isAdmin) { + $all = array_values(array_filter($all, function ($row) use ($username, $perms) { + $f = $row['folder'] ?? ''; + if ($f === '') return false; + + // Full view if canRead OR owns ancestor; otherwise allow if read_own granted + $fullView = ACL::canRead($username, $perms, $f) || FolderController::ownsFolderOrAncestor($f, $username, $perms); + $ownOnly = ACL::hasGrant($username, $f, 'read_own'); + + return $fullView || $ownOnly; + })); + } + + // 3) Optional parent filter (applies to both admin and non-admin) + if ($parent && strcasecmp($parent, 'root') !== 0) { + $pref = $parent . '/'; + $all = array_values(array_filter($all, function ($row) use ($parent, $pref) { + $f = $row['folder'] ?? ''; + return ($f === $parent) || (strpos($f, $pref) === 0); + })); + } + + echo json_encode($all); exit; } - // 2) Admin sees all; others: include folder if user has full view OR own-only view - if (!$isAdmin) { - $all = array_values(array_filter($all, function ($row) use ($username, $perms) { - $f = $row['folder'] ?? ''; - if ($f === '') return false; - - $fullView = ACL::canRead($username, $perms, $f); // owners|write|read - $ownOnly = ACL::hasGrant($username, $f, 'read_own'); // view-own - - return $fullView || $ownOnly; - })); - } - - // 3) Optional parent filter (applies to both admin and non-admin) - if ($parent && strcasecmp($parent, 'root') !== 0) { - $pref = $parent . '/'; - $all = array_values(array_filter($all, function ($row) use ($parent, $pref) { - $f = $row['folder'] ?? ''; - return ($f === $parent) || (strpos($f, $pref) === 0); - })); - } - - echo json_encode($all); - exit; -} - /* -------------------- Public Shared Folder HTML -------------------- */ public function shareFolder(): void { @@ -451,10 +503,10 @@ for ($i = $startPage; $i <= $endPage; $i++): ?> $in = json_decode(file_get_contents("php://input"), true); if (!$in || !isset($in['folder'])) { http_response_code(400); echo json_encode(["error" => "Invalid input."]); exit; } - $folder = trim($in['folder']); + $folder = trim((string)$in['folder']); $value = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60; $unit = $in['expirationUnit'] ?? 'minutes'; - $password = $in['password'] ?? ''; + $password = (string)($in['password'] ?? ''); $allowUpload = intval($in['allowUpload'] ?? 0); if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; } @@ -463,14 +515,18 @@ for ($i = $startPage; $i <= $endPage; $i++): ?> $perms = self::getPerms(); $isAdmin = self::isAdmin($perms); - if (!self::canShare($perms)) { http_response_code(403); echo json_encode(["error" => "Sharing is not permitted for your account."]); exit; } - - if (!$isAdmin) { - if (strcasecmp($folder, 'root') === 0) { http_response_code(403); echo json_encode(["error" => "Only admins may share the root folder."]); exit; } - if ($msg = self::enforceFolderScope($folder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => $msg]); exit; } + // Must have share on this folder OR be ancestor owner + if (!(ACL::canShare($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms))) { + http_response_code(403); echo json_encode(["error" => "Sharing is not permitted for your account."]); exit; } - if (!self::canBypassOwnership($perms) && !self::isFolderOwner($folder, $username)) { + // Folder-scope: need share capability within scope + if ($msg = self::enforceFolderScope($folder, $username, $perms, 'share')) { + http_response_code(403); echo json_encode(["error" => $msg]); exit; + } + + // Ownership requirement unless bypassed (allow ancestor owners) + if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the owner of this folder."]); exit; }