diff --git a/CHANGELOG.md b/CHANGELOG.md index babaa20..577e45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,69 @@ # Changelog +## Changes 10/17/2025 (v1.5.0) + +Security and permission model overhaul. Tightens access controls with explicit, server‑side ACL checks across controllers and WebDAV. Introduces `read_own` for own‑only visibility and separates view from write so uploaders can’t automatically see others’ files. Fixes session warnings and aligns the admin UI with the new capabilities. + +> **Security note** +> This release contains security hardening based on a private report (tracked via a GitHub Security Advisory, CVE pending). For responsible disclosure, details will be published alongside the advisory once available. Users should upgrade promptly. + +### Highlights + +- **ACL** + - New `read_own` bucket (own‑only visibility) alongside `owners`, `read`, `write`, `share`. + - **Semantic change:** `write` no longer implies `read`. + - `ACL::applyUserGrantsAtomic()` to atomically set per‑folder grants (`view`, `viewOwn`, `upload`, `manage`, `share`). + - `ACL::purgeUser($username)` to remove a user from all buckets (used when deleting a user). + - Auto‑heal `folder_acl.json` (ensure `root` exists; add missing buckets; de‑dupe; normalize types). + - More robust admin detection (role flag or session/admin user). + +- **Controllers** + - `FileController`: ACL + ownership enforcement for list, download, zip download, extract, move, copy, rename, create, save, tag edit, and share‑link creation. `getFileList()` now filters to the caller’s uploads when they only have `read_own` (no `read`). + - `UploadController`: requires `ACL::canWrite()` for the target folder; CSRF refresh path improved; admin bypass intact. + - `FolderController`: listing filtered by `ACL::canRead()`; optional parent filter preserved; removed name‑based ownership assumptions. + +- **Admin UI** + - Folder Access grid now includes **View (own)**; bulk toolbar actions; column alignment fixes; more space for folder names; dark‑mode polish. + +- **WebDAV** + - WebDAV now enforces ACL consistently: listing requires `read` (or `read_own` ⇒ shows only caller’s files); writes require `write`. + - Removed legacy “folderOnly” behavior — ACL is the single source of truth. + - Metadata/uploader is preserved through existing models. + +### Behavior changes (⚠️ Breaking) + +- **`write` no longer implies `read`.** + - If you want uploaders to see all files in a folder, also grant **View (all)** (`read`). + - If you want uploaders to see only their own files, grant **View (own)** (`read_own`). + +- **Removed:** legacy `folderOnly` view logic in favor of ACL‑based access. + +### Upgrade checklist + +1. Review **Folder Access** in the admin UI and grant **View (all)** or **View (own)** where appropriate. +2. For users who previously had “upload but not view,” confirm they now have **Upload** + **View (own)** (or add **View (all)** if intended). +3. Verify WebDAV behavior for representative users: + - `read` shows full listings; `read_own` lists only the caller’s files. + - Writes only succeed where `write` is granted. +4. Confirm admin can upload/move/zip across all folders (regression tested). + +### Affected areas + +- `config/config.php` — session/cookie initialization ordering; proxy header handling. +- `src/lib/ACL.php` — new bucket, semantics, healing, purge, admin detection. +- `src/controllers/FileController.php` — ACL + ownership gates across operations. +- `src/controllers/UploadController.php` — write checks + CSRF refresh handling. +- `src/controllers/FolderController.php` — ACL‑filtered listing and parent scoping. +- `public/api/admin/acl/*.php` — includes `viewOwn` round‑trip and sanitization. +- `public/js/*` & CSS — folder access grid alignment and layout fixes. +- `src/webdav/*` & `public/webdav.php` — ACL‑aware WebDAV server. + +### Credits + +- Security report acknowledged privately and will be credited in the published advisory. + +--- + ## 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 diff --git a/SECURITY.md b/SECURITY.md index 8c66985..e5430ea 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,8 +6,8 @@ We provide security fixes for the latest minor release line. | Version | Supported | |------------|-----------| -| v1.4.x | ✅ | -| < v1.4.0 | ❌ | +| v1.5.x | ✅ | +| < v1.5.0 | ❌ | ## Reporting a Vulnerability diff --git a/config/config.php b/config/config.php index d58191d..725788e 100644 --- a/config/config.php +++ b/config/config.php @@ -35,13 +35,11 @@ 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); - - +define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json'); // Encryption helpers function encryptData($data, $encryptionKey) @@ -77,16 +75,27 @@ function loadUserPermissions($username) { global $encryptionKey; $permissionsFile = USERS_DIR . 'userPermissions.json'; - if (file_exists($permissionsFile)) { - $content = file_get_contents($permissionsFile); - $decrypted = decryptData($content, $encryptionKey); - $json = ($decrypted !== false) ? $decrypted : $content; - $perms = json_decode($json, true); - if (is_array($perms) && isset($perms[$username])) { - return !empty($perms[$username]) ? $perms[$username] : false; - } + if (!file_exists($permissionsFile)) { + return false; } - return false; + + $content = file_get_contents($permissionsFile); + $decrypted = decryptData($content, $encryptionKey); + $json = ($decrypted !== false) ? $decrypted : $content; + $permsAll = json_decode($json, true); + + if (!is_array($permsAll)) { + return false; + } + + // Try exact match first, then lowercase (since we store keys lowercase elsewhere) + $uExact = (string)$username; + $uLower = strtolower($uExact); + + $row = $permsAll[$uExact] ?? $permsAll[$uLower] ?? null; + + // Normalize: always return an array when found, else false (to preserve current callers’ behavior) + return is_array($row) ? $row : false; } // Determine HTTPS usage @@ -96,25 +105,39 @@ $secure = ($envSecure !== false) : (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); // Choose session lifetime based on "remember me" cookie -$defaultSession = 7200; // 2 hours -$persistentDays = 30 * 24 * 60 * 60; // 30 days -$sessionLifetime = isset($_COOKIE['remember_me_token']) - ? $persistentDays - : $defaultSession; - -// Configure PHP session cookie and GC -session_set_cookie_params([ - 'lifetime' => $sessionLifetime, - 'path' => '/', - 'domain' => '', // adjust if you need a specific domain - 'secure' => $secure, - 'httponly' => true, - 'samesite' => 'Lax' -]); -ini_set('session.gc_maxlifetime', (string)$sessionLifetime); +$defaultSession = 7200; // 2 hours +$persistentDays = 30 * 24 * 60 * 60; // 30 days +$sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession; +/** + * Start session idempotently: + * - If no session: set cookie params + gc_maxlifetime, then session_start(). + * - If session already active: DO NOT change ini/cookie params; optionally refresh cookie expiry. + */ if (session_status() === PHP_SESSION_NONE) { + session_set_cookie_params([ + 'lifetime' => $sessionLifetime, + 'path' => '/', + 'domain' => '', // adjust if you need a specific domain + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax' + ]); + ini_set('session.gc_maxlifetime', (string)$sessionLifetime); session_start(); +} else { + // Optionally refresh the session cookie expiry to keep the user alive + $params = session_get_cookie_params(); + if ($sessionLifetime > 0) { + setcookie(session_name(), session_id(), [ + 'expires' => time() + $sessionLifetime, + 'path' => $params['path'] ?: '/', + 'domain' => $params['domain'] ?? '', + 'secure' => $secure, + 'httponly' => true, + 'samesite' => $params['samesite'] ?? 'Lax', + ]); + } } // CSRF token @@ -122,8 +145,7 @@ if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } - -// Auto‑login via persistent token +// Auto-login via persistent token if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) { $tokFile = USERS_DIR . 'persistent_tokens.json'; $tokens = []; diff --git a/public/api/admin/acl/getGrants.php b/public/api/admin/acl/getGrants.php new file mode 100644 index 0000000..6bfbf81 --- /dev/null +++ b/public/api/admin/acl/getGrants.php @@ -0,0 +1,79 @@ +'Unauthorized']); exit; +} + +$user = trim((string)($_GET['user'] ?? '')); +if ($user === '' || !preg_match(REGEX_USER, $user)) { + http_response_code(400); echo json_encode(['error'=>'Invalid user']); exit; +} + +// Build the folder list (admin sees all) +$folders = []; +try { + $rows = FolderModel::getFolderList(); + if (is_array($rows)) { + foreach ($rows as $r) { + $f = is_array($r) ? ($r['folder'] ?? '') : (string)$r; + if ($f !== '') $folders[$f] = true; + } + } +} catch (Throwable $e) { /* ignore */ } + +if (empty($folders)) { + $aclPath = META_DIR . 'folder_acl.json'; + if (is_file($aclPath)) { + $data = json_decode((string)@file_get_contents($aclPath), true); + if (is_array($data['folders'] ?? null)) { + foreach ($data['folders'] as $name => $_) $folders[$name] = true; + } + } +} + +$folderList = array_keys($folders); +if (!in_array('root', $folderList, true)) array_unshift($folderList, 'root'); + +$has = function(array $arr, string $u): bool { + foreach ($arr as $x) if (strcasecmp((string)$x, $u) === 0) return true; + return false; +}; + +$out = []; +foreach ($folderList as $f) { + $rec = ACL::explicit($f); // owners, read, write, share, read_own + + $isOwner = $has($rec['owners'], $user); + $canUpload = $isOwner || $has($rec['write'], $user); + + // IMPORTANT: full view only if owner or explicit read + $canViewAll = $isOwner || $has($rec['read'], $user); + + // own-only view reflects explicit read_own (we keep it separate even if they have full view) + $canViewOwn = $has($rec['read_own'], $user); + + // Share only if owner or explicit share + $canShare = $isOwner || $has($rec['share'], $user); + + if ($canViewAll || $canViewOwn || $canUpload || $isOwner || $canShare) { + $out[$f] = [ + 'view' => $canViewAll, + 'viewOwn' => $canViewOwn, + 'upload' => $canUpload, + 'manage' => $isOwner, + 'share' => $canShare, + ]; + } +} + +echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES); \ No newline at end of file diff --git a/public/api/admin/acl/saveGrants.php b/public/api/admin/acl/saveGrants.php new file mode 100644 index 0000000..f1e5edb --- /dev/null +++ b/public/api/admin/acl/saveGrants.php @@ -0,0 +1,105 @@ + 'Unauthorized']); + exit; +} + +$headers = function_exists('getallheaders') ? array_change_key_case(getallheaders(), CASE_LOWER) : []; +$csrf = trim($headers['x-csrf-token'] ?? ($_POST['csrfToken'] ?? '')); + +if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) { + http_response_code(403); + echo json_encode(['error' => 'Invalid CSRF token']); + exit; +} + +// ---- Helpers --------------------------------------------------------------- +/** + * Sanitize a grants map to allowed flags only: + * view | viewOwn | upload | manage | share + */ +function sanitize_grants_map(array $grants): array { + $allowed = ['view','viewOwn','upload','manage','share']; + $out = []; + foreach ($grants as $folder => $caps) { + if (!is_string($folder)) $folder = (string)$folder; + if (!is_array($caps)) $caps = []; + $row = []; + foreach ($allowed as $k) { + $row[$k] = !empty($caps[$k]); + } + // include folder even if all false (signals "remove all for this user on this folder") + $out[$folder] = $row; + } + return $out; +} + +function valid_user(string $u): bool { + return ($u !== '' && preg_match(REGEX_USER, $u)); +} + +// ---- Read JSON body -------------------------------------------------------- +$raw = file_get_contents('php://input'); +$in = json_decode((string)$raw, true); +if (!is_array($in)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid JSON']); + exit; +} + +// ---- Single user mode: { user, grants } ------------------------------------ +if (isset($in['user']) && isset($in['grants']) && is_array($in['grants'])) { + $user = trim((string)$in['user']); + if (!valid_user($user)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid user']); + exit; + } + + $grants = sanitize_grants_map($in['grants']); + + try { + $res = ACL::applyUserGrantsAtomic($user, $grants); + echo json_encode($res, JSON_UNESCAPED_SLASHES); + exit; + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]); + exit; + } +} + +// ---- Batch mode: { changes: [ { user, grants }, ... ] } -------------------- +if (isset($in['changes']) && is_array($in['changes'])) { + $updated = []; + foreach ($in['changes'] as $chg) { + if (!is_array($chg)) continue; + $user = trim((string)($chg['user'] ?? '')); + $gr = $chg['grants'] ?? null; + if (!valid_user($user) || !is_array($gr)) continue; + + try { + $res = ACL::applyUserGrantsAtomic($user, sanitize_grants_map($gr)); + $updated[$user] = $res['updated'] ?? []; + } catch (Throwable $e) { + $updated[$user] = ['error' => $e->getMessage()]; + } + } + echo json_encode(['ok' => true, 'updated' => $updated], JSON_UNESCAPED_SLASHES); + exit; +} + +// ---- Fallback -------------------------------------------------------------- +http_response_code(400); +echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']); \ No newline at end of file diff --git a/public/api/folder/capabilities.php b/public/api/folder/capabilities.php new file mode 100644 index 0000000..5ec4988 --- /dev/null +++ b/public/api/folder/capabilities.php @@ -0,0 +1,120 @@ + 'Unauthorized']); + exit; +} + +// --- helpers --- +function loadPermsFor(string $u): array { + try { + if (function_exists('loadUserPermissions')) { + $p = loadUserPermissions($u); + return is_array($p) ? $p : []; + } + if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) { + $all = userModel::getUserPermissions(); + if (is_array($all)) { + if (isset($all[$u])) return (array)$all[$u]; + $lk = strtolower($u); + if (isset($all[$lk])) return (array)$all[$lk]; + } + } + } catch (Throwable $e) {} + return []; +} + +function isAdminUser(string $u, array $perms): bool { + if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true; + if (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) return true; + $role = $_SESSION['role'] ?? null; + if ($role === 'admin' || $role === '1' || $role === 1) return true; + if ($u) { + $r = userModel::getUserRole($u); + if ($r === '1') return true; + } + return false; +} + +function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool { + if ($isAdmin) return true; + $folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']); + if (!$folderOnly) return true; + $f = trim($folder); + if ($f === '' || strcasecmp($f, 'root') === 0) return false; // non-admin folderOnly: not root + return ($f === $u) || (strpos($f, $u . '/') === 0); +} + +// --- inputs --- +$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root'; +// validate folder path: allow "root" or nested segments matching REGEX_FOLDER_NAME +if ($folder !== 'root') { + $parts = array_filter(explode('/', trim($folder, "/\\ "))); + 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; + } + } + $folder = implode('/', $parts); +} + +$perms = loadPermsFor($username); +$isAdmin = isAdminUser($username, $perms); + +// base permissions via ACL +$canRead = $isAdmin || ACL::canRead($username, $perms, $folder); +$canWrite = $isAdmin || ACL::canWrite($username, $perms, $folder); +$canShare = $isAdmin || ACL::canShare($username, $perms, $folder); + +// scope + flags +$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin); +$readOnly = !empty($perms['readOnly']); +$disableUpload = !empty($perms['disableUpload']); + +$canUpload = $canWrite && !$readOnly && !$disableUpload && $inScope; +$canCreateFolder = $canWrite && !$readOnly && $inScope; +$canRename = $canWrite && !$readOnly && $inScope; +$canDelete = $canWrite && !$readOnly && $inScope; +$canMoveIn = $canWrite && !$readOnly && $inScope; + +// (optional) owner info if you need it client-side +$owner = FolderModel::getOwnerFor($folder); + +// output +echo json_encode([ + 'user' => $username, + 'folder' => $folder, + 'isAdmin' => $isAdmin, + 'flags' => [ + 'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']), + 'readOnly' => $readOnly, + 'disableUpload' => $disableUpload, + ], + 'owner' => $owner, + 'canView' => $canRead, + 'canUpload' => $canUpload, + 'canCreate' => $canCreateFolder, + 'canRename' => $canRename, + 'canDelete' => $canDelete, + 'canMoveIn' => $canMoveIn, + 'canShare' => $canShare, +], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); \ No newline at end of file diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index 226ae04..1396385 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -1,9 +1,10 @@ +// adminPanel.js import { t } from './i18n.js'; import { loadAdminConfigFunc } from './auth.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { sendRequest } from './networkUtils.js'; -const version = "v1.4.0"; +const version = "v1.5.0"; const adminTitle = `${t("admin_panel")} ${version}`; // Translate with fallback: if t(key) just echos the key, use a readable string. @@ -12,18 +13,37 @@ const tf = (key, fallback) => { return (v && v !== key) ? v : fallback; }; +// --- tiny robust JSON helper --- +async function safeJson(res) { + const text = await res.text(); + let body = null; + try { body = text ? JSON.parse(text) : null; } catch { /* ignore */ } + if (!res.ok) { + const msg = + (body && (body.error || body.message)) || + (text && text.trim()) || + `HTTP ${res.status}`; + const err = new Error(msg); + err.status = res.status; + throw err; + } + return body ?? {}; +} + // ————— Inject updated styles ————— (function () { if (document.getElementById('adminPanelStyles')) return; const style = document.createElement('style'); style.id = 'adminPanelStyles'; style.textContent = ` - /* Modal sizing */ + /* Modal sizing */ #adminPanelModal .modal-content { max-width: 1100px; width: 50%; + background: #fff !important; + color: #000 !important; + border: 1px solid #ccc !important; } - /* Small phones: 90% width */ @media (max-width: 900px) { #adminPanelModal .modal-content { @@ -31,93 +51,98 @@ const tf = (key, fallback) => { max-width: none !important; } } - /* Dark-mode fixes */ - body.dark-mode #adminPanelModal .modal-content { - border-color: #555 !important; + body.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; } + body.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; } + body.dark-mode .form-control::placeholder { color:#888; } + + /* Section headers */ + .section-header { + background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:4px; font-weight:bold; + display:flex; align-items:center; justify-content:space-between; margin-top:16px; + } + .section-header:first-of-type { margin-top:0; } + .section-header.collapsed .material-icons { transform:rotate(-90deg); } + .section-header .material-icons { transition:transform .3s; color:#444; } + body.dark-mode .section-header { background:#3a3a3a; color:#eee; } + body.dark-mode .section-header .material-icons { color:#ccc; } + + /* Hidden by default */ + .section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; } + + /* Close button */ + #adminPanelModal .editor-close-btn { + position:absolute; top:10px; right:10px; display:flex; align-items:center; justify-content:center; + font-size:20px; font-weight:bold; cursor:pointer; z-index:1000; width:32px; height:32px; border-radius:50%; + text-align:center; line-height:30px; color:#ff4d4d; background:rgba(255,255,255,0.9); + border:2px solid transparent; transition:all .3s; + } + #adminPanelModal .editor-close-btn:hover { color:#fff; background:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); } + body.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; } + + /* Action-row */ + .action-row { display:flex; justify-content:space-between; margin-top:15px; } + + /* ---------- Folder access editor ---------- */ + .folder-access-toolbar { + display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0 6px; } - /* enforce light‐mode styling */ - #adminPanelModal .modal-content { - max-width: 1100px; - width: 50%; - background: #fff !important; - color: #000 !important; - border: 1px solid #ccc !important; - } - - /* enforce dark‐mode styling */ - body.dark-mode #adminPanelModal .modal-content { - background: #2c2c2c !important; - color: #e0e0e0 !important; - border-color: #555 !important; - } - - /* form controls in dark */ - body.dark-mode .form-control { - background-color: #333; - border-color: #555; - color: #eee; - } - body.dark-mode .form-control::placeholder { color: #888; } - - /* Section headers */ - .section-header { - background: #f5f5f5; - padding: 10px 15px; - cursor: pointer; - border-radius: 4px; - font-weight: bold; - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 16px; - } - .section-header:first-of-type { margin-top: 0; } - .section-header.collapsed .material-icons { transform: rotate(-90deg); } - .section-header .material-icons { transition: transform .3s; color: #444; } - - body.dark-mode .section-header { - background: #3a3a3a; - color: #eee; - } - body.dark-mode .section-header .material-icons { color: #ccc; } - - /* Hidden by default */ - .section-content { - display: none; - margin-left: 20px; - margin-top: 8px; - margin-bottom: 8px; - } - - /* Close button */ - #adminPanelModal .editor-close-btn { - position: absolute; top:10px; right:10px; - display:flex; align-items:center; justify-content:center; - font-size:20px; font-weight:bold; cursor:pointer; - z-index:1000; width:32px; height:32px; border-radius:50%; - text-align:center; line-height:30px; - color:#ff4d4d; background:rgba(255,255,255,0.9); - border:2px solid transparent; transition:all .3s; - } - #adminPanelModal .editor-close-btn:hover { - color:white; background:#ff4d4d; - box-shadow:0 0 6px rgba(255,77,77,.8); - transform:scale(1.05); - } - body.dark-mode #adminPanelModal .editor-close-btn { - background:rgba(0,0,0,0.6); - color:#ff4d4d; - } - - /* Action-row */ - .action-row { - display:flex; - justify-content:space-between; - margin-top:15px; - } - `; + /* Scroll area (header lives inside, sticky) */ + .folder-access-list { + --col-perm: 84px; /* width of each permission column */ + --col-folder-min: 340px; /* min width for folder names */ + max-height: 320px; + overflow: auto; + border: 1px solid #ccc; + border-radius: 6px; + padding: 0; /* no inner padding to keep grid aligned */ + } + body.dark-mode .folder-access-list { border-color:#555; } + + /* Shared grid for header + rows (MUST match) */ + .folder-access-header, + .folder-access-row { + display: grid; + grid-template-columns: minmax(var(--col-folder-min), 1fr) repeat(5, var(--col-perm)); + gap: 8px; + align-items: center; + padding: 8px 10px; + } + + /* Sticky header so it always aligns with the rows under the same scrollbar */ + .folder-access-header { + position: sticky; + top: 0; + z-index: 2; + background: #fff; + font-weight: 700; + border-bottom: 1px solid rgba(0,0,0,0.12); + } + body.dark-mode .folder-access-header { background:#2c2c2c; } + + /* Rows */ + .folder-access-row { border-bottom: 1px solid rgba(0,0,0,0.06); } + .folder-access-row:last-child { border-bottom: none; } + + /* Columns */ + .perm-col { text-align:center; white-space:nowrap; } + .folder-access-header > div { white-space: nowrap; } + + /* Folder label: show more of the path, ellipsis if needed */ + .folder-badge { + display:inline-flex; align-items:center; gap:6px; + font-weight:600; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; + min-width: 0; /* allow ellipsis in grid */ + } + + .muted { opacity:.65; font-size:.9em; } + + /* Tighter on small screens */ + @media (max-width: 900px) { + .folder-access-list { --col-perm: 72px; --col-folder-min: 240px; } + } + `; document.head.appendChild(style); })(); // ———————————————————————————————————— @@ -179,7 +204,6 @@ function toggleSection(id) { const hdr = document.getElementById(id + "Header"); const cnt = document.getElementById(id + "Content"); const isCollapsedNow = hdr.classList.toggle("collapsed"); - // collapsed class present => hide; absent => show cnt.style.display = isCollapsedNow ? "none" : "block"; if (!isCollapsedNow && id === "shareLinks") { loadShareLinksSection(); @@ -190,23 +214,12 @@ function loadShareLinksSection() { const container = document.getElementById("shareLinksContent"); container.textContent = t("loading") + "..."; - // helper: fetch one metadata file, but never throw — - // on non-2xx (including 404) or network error, resolve to {} function fetchMeta(fileName) { return fetch(`/api/admin/readMetadata.php?file=${encodeURIComponent(fileName)}`, { credentials: "include" }) - .then(resp => { - if (!resp.ok) { - // 404 or any other non-OK → treat as empty - return {}; - } - return resp.json(); - }) - .catch(() => { - // network failure, parse error, etc → also empty - return {}; - }); + .then(resp => resp.ok ? resp.json() : {}) + .catch(() => ({})); } Promise.all([ @@ -214,7 +227,6 @@ function loadShareLinksSection() { fetchMeta("share_links.json") ]) .then(([folders, files]) => { - // if *both* are empty, show "no shared links" const hasAny = Object.keys(folders).length || Object.keys(files).length; if (!hasAny) { container.innerHTML = `

${t("no_shared_links_available")}

`; @@ -252,7 +264,6 @@ function loadShareLinksSection() { container.innerHTML = html; - // wire up delete buttons container.querySelectorAll(".delete-share").forEach(btn => { btn.addEventListener("click", evt => { evt.preventDefault(); @@ -268,10 +279,7 @@ function loadShareLinksSection() { headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token }) }) - .then(res => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); - }) + .then(res => res.ok ? res.json() : Promise.reject(res)) .then(json => { if (json.success) { showToast(t("share_deleted_successfully")); @@ -293,12 +301,10 @@ function loadShareLinksSection() { }); } - export function openAdminPanel() { fetch("/api/admin/getConfig.php", { credentials: "include" }) .then(r => r.json()) .then(config => { - // apply header title + globals if (config.header_title) { document.querySelector(".header-title h1").textContent = config.header_title; window.headerTitle = config.header_title; @@ -333,17 +339,15 @@ export function openAdminPanel() {
×

${adminTitle}

- - ${[ - { id: "userManagement", label: t("user_management") }, - { id: "headerSettings", label: t("header_settings") }, - { id: "loginOptions", label: t("login_options") }, - { id: "webdav", label: "WebDAV Access" }, - { id: "upload", label: t("shared_max_upload_size_bytes_title") }, - { id: "oidc", label: t("oidc_configuration") + " & TOTP" }, - { id: "shareLinks", label: t("manage_shared_links") } - ].map(sec => ` + { id: "userManagement", label: t("user_management") }, + { id: "headerSettings", label: t("header_settings") }, + { id: "loginOptions", label: t("login_options") }, + { id: "webdav", label: "WebDAV Access" }, + { id: "upload", label: t("shared_max_upload_size_bytes_title") }, + { id: "oidc", label: t("oidc_configuration") + " & TOTP" }, + { id: "shareLinks", label: t("manage_shared_links") } + ].map(sec => ` @@ -372,13 +376,15 @@ export function openAdminPanel() { .addEventListener("click", () => toggleSection(id)); }); - // Populate each section’s CONTENT: // — User Mgmt — document.getElementById("userManagementContent").innerHTML = ` - + + `; + + document.getElementById("adminOpenAddUser") .addEventListener("click", () => { toggleVisibility("addUserModal", true); @@ -458,7 +464,6 @@ export function openAdminPanel() { } }); }); - // If authBypass is checked, clear the other three document.getElementById("authBypass").addEventListener("change", e => { if (e.target.checked) { ["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"] @@ -466,6 +471,27 @@ export function openAdminPanel() { } }); + // after you set #userManagementContent.innerHTML (right after those three buttons are inserted) +const userMgmt = document.getElementById("userManagementContent"); + +// defensive: remove any old listener first +userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick); + +window.__userMgmtDelegatedClick = (e) => { + const flagsBtn = e.target.closest("#adminOpenUserFlags"); + if (flagsBtn) { + e.preventDefault(); + openUserFlagsModal(); + } + const folderBtn = e.target.closest("#adminOpenUserPermissions"); + if (folderBtn) { + e.preventDefault(); + openUserPermissionsModal(); + } +}; + +userMgmt?.addEventListener("click", window.__userMgmtDelegatedClick); + // Initialize inputs from config + capture document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; @@ -479,7 +505,6 @@ export function openAdminPanel() { } else { // modal already exists → just refresh values & re-show mdl.style.display = "flex"; - // update dark/light as above... document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; @@ -533,9 +558,7 @@ function handleSave() { enableWebDAV: eWD, sharedMaxUploadSize: sMax, globalOtpauthUrl: gURL - }, { - "X-CSRF-Token": window.csrfToken - }) + }, { "X-CSRF-Token": window.csrfToken }) .then(res => { if (res.success) { showToast(t("settings_updated_successfully"), "success"); @@ -556,49 +579,263 @@ export async function closeAdminPanel() { document.getElementById("adminPanelModal").style.display = "none"; } -// --- New: User Permissions Modal --- +/* =========================== + New: Folder Access (ACL) UI + =========================== */ + +let __allFoldersCache = null; // array of folder strings +async function getAllFolders() { + if (__allFoldersCache) return __allFoldersCache.slice(); + const res = await fetch('/api/folder/getFolderList.php', { credentials: 'include' }); + const data = await safeJson(res).catch(() => []); + const list = Array.isArray(data) + ? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean) + : []; + // Keep "root" first, hide special internal ones + const hidden = new Set(["profile_pics", "trash"]); + const cleaned = list + .filter(f => f && !hidden.has(f.toLowerCase())) + .sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b))); + __allFoldersCache = cleaned; + return cleaned.slice(); +} + +async function getUserGrants(username) { + const res = await fetch(`/api/admin/acl/getGrants.php?user=${encodeURIComponent(username)}`, { + credentials: 'include' + }); + const data = await safeJson(res).catch(() => ({})); + // expected: { grants: { "folder/name": {view,upload,manage,share}, ... } } + return (data && data.grants) ? data.grants : {}; +} + +function renderFolderGrantsUI(username, container, folders, grants) { + container.innerHTML = ""; + + // toolbar + const toolbar = document.createElement('div'); + toolbar.className = 'folder-access-toolbar'; + toolbar.innerHTML = ` + + + + + + + (${tf('applies_to_filtered','applies to filtered list')}) + `; + container.appendChild(toolbar); + + // list (will contain sticky header + rows) + const list = document.createElement('div'); + list.className = 'folder-access-list'; + container.appendChild(list); + + const headerHtml = ` +
+
${tf('folder', 'Folder')}
+
${tf('view_all','View (all)')}
+
${tf('view_own','View (own)')}
+
${tf('upload','Upload')}
+
${tf('manage','Manage')}
+
${tf('share','Share')}
+
+ `; + + function rowHtml(folder) { + const g = grants[folder] || {}; + const name = folder === 'root' ? '(Root)' : folder; + return ` +
+
folder${name}
+
+
+
+
+
+
+ `; + } + + // Dependencies + function applyDeps(row) { + const cbView = row.querySelector('input[data-cap="view"]'); + const cbViewOwn = row.querySelector('input[data-cap="viewOwn"]'); + const cbUpload = row.querySelector('input[data-cap="upload"]'); + const cbManage = row.querySelector('input[data-cap="manage"]'); + const cbShare = row.querySelector('input[data-cap="share"]'); + + // Manage ⇒ full view + upload + share + if (cbManage.checked) { + cbView.checked = true; + cbUpload.checked = true; + cbShare.checked = true; + } + + // Share ⇒ full view + if (cbShare.checked) cbView.checked = true; + + // Upload ⇒ at least own view + if (cbUpload.checked && !cbView.checked && !cbViewOwn.checked) { + cbViewOwn.checked = true; + } + + // Full view supersedes own-only + if (cbView.checked || cbManage.checked) { + cbViewOwn.checked = false; + cbViewOwn.disabled = true; + cbViewOwn.title = tf('full_view_supersedes_own','Full view supersedes own-only'); + } else { + cbViewOwn.disabled = false; + cbViewOwn.removeAttribute('title'); + } + + // Owners can always share (UI hint only) + if (cbManage.checked) { + cbShare.disabled = true; + cbShare.title = tf('owners_can_always_share', 'Owners can always share'); + } else { + cbShare.disabled = false; + cbShare.removeAttribute('title'); + } + } + + function wireRow(row) { + const cbView = row.querySelector('input[data-cap="view"]'); + const cbViewOwn = row.querySelector('input[data-cap="viewOwn"]'); + const cbUpload = row.querySelector('input[data-cap="upload"]'); + const cbManage = row.querySelector('input[data-cap="manage"]'); + const cbShare = row.querySelector('input[data-cap="share"]'); + + cbUpload.addEventListener('change', () => applyDeps(row)); + cbShare .addEventListener('change', () => applyDeps(row)); + cbManage.addEventListener('change', () => applyDeps(row)); + + cbView.addEventListener('change', () => { + if (!cbView.checked) { cbManage.checked = false; cbShare.checked = false; } + applyDeps(row); + }); + cbViewOwn.addEventListener('change', () => applyDeps(row)); + + applyDeps(row); + } + + function render(filter = "") { + const f = filter.trim().toLowerCase(); + const rowsHtml = folders + .filter(x => !f || x.toLowerCase().includes(f)) + .map(rowHtml) + .join(""); + + list.innerHTML = headerHtml + rowsHtml; + + list.querySelectorAll('.folder-access-row').forEach(wireRow); + } + + // initial render + filter wire-up + render(); + const filterInput = toolbar.querySelector('input[type="text"]'); + filterInput.addEventListener('input', () => render(filterInput.value)); + + // bulk toggles + toolbar.querySelectorAll('input[type="checkbox"][data-bulk]').forEach(bulk => { + bulk.addEventListener('change', () => { + const which = bulk.dataset.bulk; + const f = (filterInput.value || "").trim().toLowerCase(); + + list.querySelectorAll('.folder-access-row').forEach(row => { + const folder = row.dataset.folder || ""; + if (f && !folder.toLowerCase().includes(f)) return; + + const target = row.querySelector(`input[data-cap="${which}"]`); + if (!target) return; + + target.checked = bulk.checked; + + // simple implications for bulk; detailed state handled by applyDeps + if (which === 'manage' && bulk.checked) { + row.querySelector('input[data-cap="view"]').checked = true; + row.querySelector('input[data-cap="upload"]').checked = true; + row.querySelector('input[data-cap="share"]').checked = true; + } + if (which === 'share' && bulk.checked) { + row.querySelector('input[data-cap="view"]').checked = true; + } + if (which === 'upload' && bulk.checked) { + const v = row.querySelector('input[data-cap="view"]'); + const vo = row.querySelector('input[data-cap="viewOwn"]'); + if (!v.checked && !vo.checked) vo.checked = true; + } + if (which === 'view' && !bulk.checked) { + row.querySelector('input[data-cap="manage"]').checked = false; + row.querySelector('input[data-cap="share"]').checked = false; + } + + applyDeps(row); + }); + }); + }); +} + +// Collect grants from a user's UI +function collectGrantsFrom(container) { + const out = {}; + container.querySelectorAll('.folder-access-row').forEach(row => { + const folder = row.dataset.folder; + if (!folder) return; + const g = { + view: row.querySelector('input[data-cap="view"]').checked, + viewOwn: row.querySelector('input[data-cap="viewOwn"]').checked, + upload: row.querySelector('input[data-cap="upload"]').checked, + manage: row.querySelector('input[data-cap="manage"]').checked, + share: row.querySelector('input[data-cap="share"]').checked + }; + if (g.view || g.viewOwn || g.upload || g.manage || g.share) out[folder] = g; + }); + return out; +} + +// --- New: User Permissions (Folder Access) Modal --- export function openUserPermissionsModal() { let userPermissionsModal = document.getElementById("userPermissionsModal"); const isDarkMode = document.body.classList.contains("dark-mode"); const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; const modalContentStyles = ` - background: ${isDarkMode ? "#2c2c2c" : "#fff"}; - color: ${isDarkMode ? "#e0e0e0" : "#000"}; - padding: 20px; - max-width: 500px; - width: 90%; - border-radius: 8px; - position: relative; - `; + background: ${isDarkMode ? "#2c2c2c" : "#fff"}; + color: ${isDarkMode ? "#e0e0e0" : "#000"}; + padding: 20px; + max-width: 780px; + width: 95%; + border-radius: 8px; + position: relative; + `; if (!userPermissionsModal) { userPermissionsModal = document.createElement("div"); userPermissionsModal.id = "userPermissionsModal"; userPermissionsModal.style.cssText = ` - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background-color: ${overlayBackground}; - display: flex; - justify-content: center; - align-items: center; - z-index: 3500; - `; + position: fixed; + top: 0; left: 0; width: 100vw; height: 100vh; + background-color: ${overlayBackground}; + display: flex; justify-content: center; align-items: center; + z-index: 3500; + `; userPermissionsModal.innerHTML = ` - - `; + + `; document.body.appendChild(userPermissionsModal); document.getElementById("closeUserPermissionsModal").addEventListener("click", () => { userPermissionsModal.style.display = "none"; @@ -606,149 +843,260 @@ export function openUserPermissionsModal() { document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => { userPermissionsModal.style.display = "none"; }); - document.getElementById("saveUserPermissionsBtn").addEventListener("click", () => { - // Collect permissions data from each user row. + document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => { + // Collect grants for every expanded user (or all rows that have a grants list) const rows = userPermissionsModal.querySelectorAll(".user-permission-row"); - const permissionsData = []; + let saves = []; rows.forEach(row => { - const g = k => row.querySelector(`input[data-permission='${k}']`)?.checked ?? false; - permissionsData.push({ - 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"), - }); + const username = row.getAttribute("data-username"); + const grantsBox = row.querySelector(".folder-grants-box"); + if (!username || !grantsBox) return; + const grants = collectGrantsFrom(grantsBox); + saves.push({ user: username, grants }); }); - // Send the permissionsData to the server. - sendRequest("/api/updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken }) - .then(response => { - if (response.success) { - showToast(t("user_permissions_updated_successfully")); - userPermissionsModal.style.display = "none"; - } else { - showToast(t("error_updating_permissions") + ": " + (response.error || t("unknown_error"))); - } - }) - .catch(() => { - showToast(t("error_updating_permissions")); - }); + + try { + if (saves.length === 0) { + showToast(tf("nothing_to_save", "Nothing to save")); + return; + } + for (const payload of saves) { + await sendRequest("/api/admin/acl/saveGrants.php", "POST", payload, { "X-CSRF-Token": window.csrfToken }); + } + showToast(tf("user_permissions_updated_successfully", "User permissions updated successfully")); + userPermissionsModal.style.display = "none"; + } catch (err) { + console.error(err); + showToast(tf("error_updating_permissions", "Error updating permissions"), "error"); + } }); } else { userPermissionsModal.style.display = "flex"; } - // Load the list of users into the modal. + loadUserPermissionsList(); } -function loadUserPermissionsList() { - const listContainer = document.getElementById("userPermissionsList"); - if (!listContainer) return; - listContainer.innerHTML = ""; - - // First, fetch the current permissions from the server. - fetch("/api/getUserPermissions.php", { credentials: "include" }) - .then(response => response.json()) - .then(permissionsData => { - // Then, fetch the list of users. - return fetch("/api/getUsers.php", { credentials: "include" }) - .then(response => response.json()) - .then(usersData => { - const users = Array.isArray(usersData) ? usersData : (usersData.users || []); - if (users.length === 0) { - listContainer.innerHTML = "

" + t("no_users_found") + "

"; - return; - } - users.forEach(user => { - // Skip admin users. - if ((user.role && user.role === "1") || user.username.toLowerCase() === "admin") return; - - // Use stored permissions if available; otherwise fall back to defaults. - const defaultPerm = { - 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 (collapsed by default) -const row = document.createElement("div"); -row.classList.add("user-permission-row"); -row.setAttribute("data-username", user.username); -row.style.padding = "6px 0"; - -// helper for checkbox checked state -const checked = key => (userPerm && userPerm[key]) ? "checked" : ""; - -// header + caret -row.innerHTML = ` - - - - -
-`; - -// toggle open/closed on click + Enter/Space -const header = row.querySelector(".user-perm-header"); -const details = row.querySelector(".user-perm-details"); -const caret = row.querySelector(".perm-caret"); - -function toggleOpen() { - const willShow = details.style.display === "none"; - details.style.display = willShow ? "grid" : "none"; - header.setAttribute("aria-expanded", willShow ? "true" : "false"); - caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)"; +async function fetchAllUsers() { + const r = await fetch("/api/getUsers.php", { credentials: "include" }); + return await r.json(); // array of { username, role } } -header.addEventListener("click", toggleOpen); -header.addEventListener("keydown", e => { - if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); } -}); +// Returns a map of { username: { readOnly, folderOnly, disableUpload, canShare, bypassOwnership } } +async function fetchAllUserFlags() { + const r = await fetch("/api/getUserPermissions.php", { credentials: "include" }); + const data = await r.json(); + // remove deprecated flag if present, so UI never shows it + if (data && typeof data === "object") { + const map = data.allPermissions || data.permissions || data; + if (map && typeof map === "object") { + Object.values(map).forEach(u => { if (u && typeof u === "object") delete u.folderOnly; }); + } + } + // Accept both shapes: {users:[...]} or a plain object map + if (Array.isArray(data)) { + // unlikely, but normalize + const out = {}; + data.forEach(u => { if (u.username) out[u.username] = u; }); + return out; + } + if (data && data.allPermissions) return data.allPermissions; + if (data && data.permissions) return data.permissions; + return data || {}; +} -listContainer.appendChild(row); - listContainer.appendChild(row); - }); - }); - }) - .catch(() => { - listContainer.innerHTML = "

" + t("error_loading_users") + "

"; +function flagRow(u, flags) { + const f = flags[u.username] || {}; + const isAdmin = String(u.role) === "1" || u.username.toLowerCase() === "admin"; + if (isAdmin) return ""; // skip admins here + return ` + + ${u.username} + + + + + + `; +} + +export async function openUserFlagsModal() { + let modal = document.getElementById("userFlagsModal"); + if (!modal) { + modal = document.createElement("div"); + modal.id = "userFlagsModal"; + modal.style.cssText = ` + position:fixed; inset:0; background:rgba(0,0,0,.5); + display:flex; align-items:center; justify-content:center; z-index:3600; + `; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + document.getElementById("closeUserFlagsModal").onclick = () => modal.style.display = "none"; + document.getElementById("cancelUserFlags").onclick = () => modal.style.display = "none"; + document.getElementById("saveUserFlags").onclick = saveUserFlags; + } + modal.style.display = "flex"; + loadUserFlagsList(); +} + +async function loadUserFlagsList() { + const body = document.getElementById("userFlagsBody"); + body.textContent = `${t("loading")}…`; + try { + const users = await fetchAllUsers(); // [{username, role}] + const flagsMap = await fetchAllUserFlags(); // { username: {…} } + const rows = users.map(u => flagRow(u, flagsMap)).filter(Boolean).join(""); + body.innerHTML = ` + + + + + + + + + + + ${rows || ``} +
${t("user")}${t("read_only")}${t("disable_upload")}${t("can_share")}bypassOwnership
${t("no_users_found")}
+ `; + } catch (e) { + console.error(e); + body.innerHTML = `
${t("error_loading_users")}
`; + } +} + +async function saveUserFlags() { + const body = document.getElementById("userFlagsBody"); + const rows = body.querySelectorAll("tbody tr[data-username]"); + const permissions = []; + rows.forEach(tr => { + const username = tr.getAttribute("data-username"); + const get = k => tr.querySelector(`input[data-flag="${k}"]`).checked; + permissions.push({ + username, + readOnly: get("readOnly"), + disableUpload: get("disableUpload"), + canShare: get("canShare"), + bypassOwnership: get("bypassOwnership") }); + }); + + try { + // reuse your existing endpoint + const res = await sendRequest("/api/updateUserPermissions.php", "PUT", + { permissions }, + { "X-CSRF-Token": window.csrfToken } + ); + if (res && res.success) { + showToast(tf("user_permissions_updated_successfully", "User permissions updated successfully")); + document.getElementById("userFlagsModal").style.display = "none"; + } else { + showToast(tf("error_updating_permissions", "Error updating permissions"), "error"); + } + } catch (e) { + console.error(e); + showToast(tf("error_updating_permissions", "Error updating permissions"), "error"); + } +} + +async function loadUserPermissionsList() { + const listContainer = document.getElementById("userPermissionsList"); + if (!listContainer) return; + listContainer.innerHTML = `

${t("loading")}…

`; + + try { + const usersRes = await fetch("/api/getUsers.php", { credentials: "include" }); + const usersData = await safeJson(usersRes); + const users = Array.isArray(usersData) ? usersData : (usersData.users || []); + if (!users.length) { + listContainer.innerHTML = "

" + t("no_users_found") + "

"; + return; + } + + // Preload folders once (admin should see all) + const folders = await getAllFolders(); + + listContainer.innerHTML = ""; // clear + users.forEach(user => { + // Skip admins + if ((user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin") return; + + const row = document.createElement("div"); + row.classList.add("user-permission-row"); + row.setAttribute("data-username", user.username); + row.style.padding = "6px 0"; + + row.innerHTML = ` + + + + +
+ `; + + const header = row.querySelector(".user-perm-header"); + const details = row.querySelector(".user-perm-details"); + const caret = row.querySelector(".perm-caret"); + const grantsBox = row.querySelector(".folder-grants-box"); + + async function ensureLoaded() { + if (grantsBox.dataset.loaded === "1") return; + try { + const grants = await getUserGrants(user.username); + renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], grants); + grantsBox.dataset.loaded = "1"; + } catch (e) { + console.error(e); + grantsBox.innerHTML = `
${tf("error_loading_user_grants", "Error loading user grants")}
`; + } + } + + function toggleOpen() { + const willShow = details.style.display === "none"; + details.style.display = willShow ? "block" : "none"; + header.setAttribute("aria-expanded", willShow ? "true" : "false"); + caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)"; + if (willShow) ensureLoaded(); + } + + header.addEventListener("click", toggleOpen); + header.addEventListener("keydown", e => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); } + }); + + listContainer.appendChild(row); + }); + } catch (err) { + console.error(err); + listContainer.innerHTML = "

" + t("error_loading_users") + "

"; + } } \ No newline at end of file diff --git a/public/js/fileListView.js b/public/js/fileListView.js index 3d49079..1c8a662 100644 --- a/public/js/fileListView.js +++ b/public/js/fileListView.js @@ -11,12 +11,12 @@ import { updateRowHighlight, toggleRowSelection, attachEnterKeyListener -} from './domUtils.js'; -import { t } from './i18n.js'; -import { bindFileListContextMenu } from './fileMenu.js'; -import { openDownloadModal } from './fileActions.js'; -import { openTagModal, openMultiTagModal } from './fileTags.js'; -import { + } from './domUtils.js'; + import { t } from './i18n.js'; + import { bindFileListContextMenu } from './fileMenu.js'; + import { openDownloadModal } from './fileActions.js'; + import { openTagModal, openMultiTagModal } from './fileTags.js'; + import { getParentFolder, updateBreadcrumbTitle, setupBreadcrumbDelegation, @@ -24,801 +24,828 @@ import { hideFolderManagerContextMenu, openRenameFolderModal, openDeleteFolderModal -} from './folderManager.js'; -import { openFolderShareModal } from './folderShareModal.js'; -import { + } from './folderManager.js'; + import { openFolderShareModal } from './folderShareModal.js'; + import { folderDragOverHandler, folderDragLeaveHandler, folderDropHandler -} from './fileDragDrop.js'; - -export let fileData = []; -export let sortOrder = { column: "uploaded", ascending: true }; - -// Hide "Edit" for files >10 MiB -const MAX_EDIT_BYTES = 10 * 1024 * 1024; - -// Latest-response-wins guard (prevents double render/flicker if loadFileList gets called twice) -let __fileListReqSeq = 0; - -window.itemsPerPage = parseInt( + } from './fileDragDrop.js'; + + export let fileData = []; + export let sortOrder = { column: "uploaded", ascending: true }; + + // Hide "Edit" for files >10 MiB + const MAX_EDIT_BYTES = 10 * 1024 * 1024; + + // Latest-response-wins guard (prevents double render/flicker if loadFileList gets called twice) + let __fileListReqSeq = 0; + + window.itemsPerPage = parseInt( localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10', 10 -); -window.currentPage = window.currentPage || 1; -window.viewMode = localStorage.getItem("viewMode") || "table"; - -// Global flag for advanced search mode. -window.advancedSearchEnabled = false; - -/** - * --- Helper Functions --- - */ - -// Safely parse JSON; if server returned HTML/text, throw it as a readable error. -async function safeJson(res) { + ); + window.currentPage = window.currentPage || 1; + window.viewMode = localStorage.getItem("viewMode") || "table"; + + // Global flag for advanced search mode. + window.advancedSearchEnabled = false; + + /* =========================================================== + SECURITY: build file URLs only via the API (no /uploads) + =========================================================== */ + function apiFileUrl(folder, name, inline = false) { + const f = folder && folder !== "root" ? folder : "root"; + const q = new URLSearchParams({ + folder: f, + file: name, + inline: inline ? "1" : "0", + t: String(Date.now()) // cache-bust + }); + return `/api/file/download.php?${q.toString()}`; + } + + /* ----------------------------- + Helper: robust JSON handling + ----------------------------- */ + // Parse JSON if possible; throw on non-2xx with useful message & status + 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'}`); + let body = null; + try { body = text ? JSON.parse(text) : null; } catch { /* ignore */ } + + if (!res.ok) { + const msg = + (body && (body.error || body.message)) || + (text && text.trim()) || + `HTTP ${res.status}`; + const err = new Error(msg); + err.status = res.status; + throw err; } -} - -/** - * Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes. - */ -function parseSizeToBytes(sizeStr) { + return body ?? {}; + } + + /** + * Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes. + */ + function parseSizeToBytes(sizeStr) { if (!sizeStr) return 0; let s = sizeStr.trim(); let value = parseFloat(s); let upper = s.toUpperCase(); if (upper.includes("KB")) { - value *= 1024; + value *= 1024; } else if (upper.includes("MB")) { - value *= 1024 * 1024; + value *= 1024 * 1024; } else if (upper.includes("GB")) { - value *= 1024 * 1024 * 1024; + value *= 1024 * 1024 * 1024; } return value; -} - -/** - * Format the total bytes as a human-readable string. - */ -function formatSize(totalBytes) { + } + + /** + * Format the total bytes as a human-readable string. + */ + function formatSize(totalBytes) { if (totalBytes < 1024) { - return totalBytes + " Bytes"; + return totalBytes + " Bytes"; } else if (totalBytes < 1024 * 1024) { - return (totalBytes / 1024).toFixed(2) + " KB"; + return (totalBytes / 1024).toFixed(2) + " KB"; } else if (totalBytes < 1024 * 1024 * 1024) { - return (totalBytes / (1024 * 1024)).toFixed(2) + " MB"; + return (totalBytes / (1024 * 1024)).toFixed(2) + " MB"; } else { - return (totalBytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; + return (totalBytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; } -} - -/** - * Build the folder summary HTML using the filtered file list. - */ -function buildFolderSummary(filteredFiles) { + } + + /** + * Build the folder summary HTML using the filtered file list. + */ + function buildFolderSummary(filteredFiles) { const totalFiles = filteredFiles.length; const totalBytes = filteredFiles.reduce((sum, file) => { - return sum + parseSizeToBytes(file.size); + return sum + parseSizeToBytes(file.size); }, 0); const sizeStr = formatSize(totalBytes); return `${t('total_files')}: ${totalFiles}  |  ${t('total_size')}: ${sizeStr}`; -} - -/** - * --- Advanced Search Toggle --- - * Toggles advanced search mode. When enabled, the search will include additional keys (e.g. "content"). - */ -function toggleAdvancedSearch() { + } + + /** + * Advanced Search toggle + */ + function toggleAdvancedSearch() { window.advancedSearchEnabled = !window.advancedSearchEnabled; const advancedBtn = document.getElementById("advancedSearchToggle"); if (advancedBtn) { - advancedBtn.textContent = window.advancedSearchEnabled ? "Basic Search" : "Advanced Search"; + advancedBtn.textContent = window.advancedSearchEnabled ? "Basic Search" : "Advanced Search"; } - // Re-run the file table rendering with updated search settings. renderFileTable(window.currentFolder); -} - -window.imageCache = window.imageCache || {}; -function cacheImage(imgElem, key) { - // Save the current src for future renders. + } + + window.imageCache = window.imageCache || {}; + function cacheImage(imgElem, key) { window.imageCache[key] = imgElem.src; -} -window.cacheImage = cacheImage; - -/** - * --- Fuse.js Search Helper --- - * Uses Fuse.js to perform a fuzzy search on fileData. - * By default, searches over file name, uploader, and tag names. - * When advanced search is enabled, it also includes the 'content' property. - */ -function searchFiles(searchTerm) { + } + window.cacheImage = cacheImage; + + /** + * Fuse.js fuzzy search helper + */ + function searchFiles(searchTerm) { if (!searchTerm) return fileData; - - // Define search keys. + let keys = [ - { name: 'name', weight: 0.1 }, - { name: 'uploader', weight: 0.1 }, - { name: 'tags.name', weight: 0.1 } + { name: 'name', weight: 0.1 }, + { name: 'uploader', weight: 0.1 }, + { name: 'tags.name', weight: 0.1 } ]; if (window.advancedSearchEnabled) { - keys.push({ name: 'content', weight: 0.7 }); + keys.push({ name: 'content', weight: 0.7 }); } - + const options = { - keys: keys, - threshold: 0.4, - minMatchCharLength: 2, - ignoreLocation: true + keys: keys, + threshold: 0.4, + minMatchCharLength: 2, + ignoreLocation: true }; - + const fuse = new Fuse(fileData, options); let results = fuse.search(searchTerm); return results.map(result => result.item); -} - -/** - * --- VIEW MODE TOGGLE BUTTON & Helpers --- - */ -export function createViewToggleButton() { + } + + /** + * View mode toggle + */ + export function createViewToggleButton() { let toggleBtn = document.getElementById("toggleViewBtn"); if (!toggleBtn) { - toggleBtn = document.createElement("button"); - toggleBtn.id = "toggleViewBtn"; - toggleBtn.classList.add("btn", "btn-toggleview"); - - // Set initial icon and tooltip based on current view mode. - if (window.viewMode === "gallery") { - toggleBtn.innerHTML = 'view_list'; - toggleBtn.title = t("switch_to_table_view"); - } else { - toggleBtn.innerHTML = 'view_module'; - toggleBtn.title = t("switch_to_gallery_view"); - } - - // Insert the button before the last button in the header. - const headerButtons = document.querySelector(".header-buttons"); - if (headerButtons && headerButtons.lastElementChild) { - headerButtons.insertBefore(toggleBtn, headerButtons.lastElementChild); - } else if (headerButtons) { - headerButtons.appendChild(toggleBtn); - } + toggleBtn = document.createElement("button"); + toggleBtn.id = "toggleViewBtn"; + toggleBtn.classList.add("btn", "btn-toggleview"); + + if (window.viewMode === "gallery") { + toggleBtn.innerHTML = 'view_list'; + toggleBtn.title = t("switch_to_table_view"); + } else { + toggleBtn.innerHTML = 'view_module'; + toggleBtn.title = t("switch_to_gallery_view"); + } + + const headerButtons = document.querySelector(".header-buttons"); + if (headerButtons && headerButtons.lastElementChild) { + headerButtons.insertBefore(toggleBtn, headerButtons.lastElementChild); + } else if (headerButtons) { + headerButtons.appendChild(toggleBtn); + } } - + toggleBtn.onclick = () => { - window.viewMode = window.viewMode === "gallery" ? "table" : "gallery"; - localStorage.setItem("viewMode", window.viewMode); - loadFileList(window.currentFolder); - if (window.viewMode === "gallery") { - toggleBtn.innerHTML = 'view_list'; - toggleBtn.title = t("switch_to_table_view"); - } else { - toggleBtn.innerHTML = 'view_module'; - toggleBtn.title = t("switch_to_gallery_view"); - } + window.viewMode = window.viewMode === "gallery" ? "table" : "gallery"; + localStorage.setItem("viewMode", window.viewMode); + loadFileList(window.currentFolder); + if (window.viewMode === "gallery") { + toggleBtn.innerHTML = 'view_list'; + toggleBtn.title = t("switch_to_table_view"); + } else { + toggleBtn.innerHTML = 'view_module'; + toggleBtn.title = t("switch_to_gallery_view"); + } }; - + return toggleBtn; -} - -export function formatFolderName(folder) { + } + + export function formatFolderName(folder) { if (folder === "root") return "(Root)"; return folder - .replace(/[_-]+/g, " ") - .replace(/\b\w/g, char => char.toUpperCase()); -} - -// Expose inline DOM helpers. -window.toggleRowSelection = toggleRowSelection; -window.updateRowHighlight = updateRowHighlight; - -export async function loadFileList(folderParam) { + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, char => char.toUpperCase()); + } + + // Expose inline DOM helpers. + window.toggleRowSelection = toggleRowSelection; + window.updateRowHighlight = updateRowHighlight; + + export async function loadFileList(folderParam) { const reqId = ++__fileListReqSeq; // latest call wins const folder = folderParam || "root"; const fileListContainer = document.getElementById("fileList"); const actionsContainer = document.getElementById("fileListActions"); - + // 1) show loader (only this request is allowed to render) fileListContainer.style.visibility = "hidden"; fileListContainer.innerHTML = "
Loading files...
"; - + 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()}`, - { credentials: 'include' } - ); - const foldersPromise = fetch( - `/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`, - { credentials: 'include' } - ); - - // ----- FILES FIRST ----- - const filesRes = await filesPromise; - - if (filesRes.status === 401) { - window.location.href = "/api/auth/logout.php"; - throw new Error("Unauthorized"); - } - - 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 + // Kick off both in parallel, but render as soon as FILES are ready + 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; + + if (filesRes.status === 401) { + // session expired — bounce to logout + window.location.href = "/api/auth/logout.php"; + throw new Error("Unauthorized"); + } + if (filesRes.status === 403) { + // forbidden — friendly message, keep UI responsive + fileListContainer.innerHTML = ` +
+ ${t("no_access_to_resource") || "You don't have access to this folder."} +
`; + showToast(t("no_access_to_resource") || "You don't have access to this folder.", "error"); + return []; + } + + 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 []; + + // 3) clear loader + fileListContainer.innerHTML = ""; + + // 4) handle “no files” case + if (!data.files || Object.keys(data.files).length === 0) { if (reqId !== __fileListReqSeq) return []; - - // 3) clear loader (still only if this request is the latest) - fileListContainer.innerHTML = ""; - - // 4) handle “no files” case - if (!data.files || Object.keys(data.files).length === 0) { - if (reqId !== __fileListReqSeq) return []; - fileListContainer.textContent = t("no_files_found"); - - // hide summary + slider - const summaryElem = document.getElementById("fileSummary"); - if (summaryElem) summaryElem.style.display = "none"; - const sliderContainer = document.getElementById("viewSliderContainer"); - if (sliderContainer) sliderContainer.style.display = "none"; - - // hide folder strip for now; we’ll re-show it after folders load (below) - const strip = document.getElementById("folderStripContainer"); - if (strip) strip.style.display = "none"; - - updateFileActionButtons(); - fileListContainer.style.visibility = "visible"; - return []; - } - - // 5) normalize files array - if (!Array.isArray(data.files)) { - data.files = Object.entries(data.files).map(([name, meta]) => { - meta.name = name; - return meta; - }); - } - - data.files = data.files.map(f => { - f.fullName = (f.path || f.name).trim().toLowerCase(); - - // Prefer numeric size if your API provides it; otherwise parse the "1.2 MB" string - let bytes = Number.isFinite(f.sizeBytes) - ? f.sizeBytes - : parseSizeToBytes(String(f.size || "")); - - if (!Number.isFinite(bytes)) bytes = Infinity; - - // extension policy + size policy - f.editable = canEditFile(f.name) && (bytes <= MAX_EDIT_BYTES); - - f.folder = folder; - return f; - }); - fileData = data.files; - - // Decide editability BEFORE render to avoid any post-render “blink” - data.files = data.files.map(f => { - f.fullName = (f.path || f.name).trim().toLowerCase(); - - // extension policy - const extOk = canEditFile(f.name); - - // prefer numeric byte size if API provides it; otherwise parse "12.3 MB" strings - let bytes = Infinity; - if (Number.isFinite(f.sizeBytes)) { - bytes = f.sizeBytes; - } else if (f.size != null && String(f.size).trim() !== "") { - bytes = parseSizeToBytes(String(f.size)); - } - - f.editable = extOk && (bytes <= MAX_EDIT_BYTES); - f.folder = folder; - return f; - }); - fileData = data.files; - - // If stale, stop before any DOM updates - if (reqId !== __fileListReqSeq) return []; - - // 6) inject summary + slider - if (actionsContainer) { - // a) summary - let summaryElem = document.getElementById("fileSummary"); - if (!summaryElem) { - summaryElem = document.createElement("div"); - summaryElem.id = "fileSummary"; - summaryElem.style.cssText = "float:right; margin:0 60px 0 auto; font-size:0.9em;"; - actionsContainer.appendChild(summaryElem); - } - summaryElem.style.display = "block"; - summaryElem.innerHTML = buildFolderSummary(fileData); - - // b) slider - const viewMode = window.viewMode || "table"; - let sliderContainer = document.getElementById("viewSliderContainer"); - if (!sliderContainer) { - sliderContainer = document.createElement("div"); - sliderContainer.id = "viewSliderContainer"; - sliderContainer.style.cssText = "display:inline-flex; align-items:center; margin-right:auto; font-size:0.9em;"; - actionsContainer.insertBefore(sliderContainer, summaryElem); - } else { - sliderContainer.style.display = "inline-flex"; - } - - if (viewMode === "gallery") { - const w = window.innerWidth; - let maxCols; - if (w < 600) maxCols = 1; - else if (w < 900) maxCols = 2; - else if (w < 1200) maxCols = 4; - else maxCols = 6; - - const currentCols = Math.min( - parseInt(localStorage.getItem("galleryColumns") || "3", 10), - maxCols - ); - - sliderContainer.innerHTML = ` - - - ${currentCols} - `; - const gallerySlider = document.getElementById("galleryColumnsSlider"); - const galleryValue = document.getElementById("galleryColumnsValue"); - gallerySlider.oninput = e => { - const v = +e.target.value; - localStorage.setItem("galleryColumns", v); - galleryValue.textContent = v; - document.querySelector(".gallery-container") - ?.style.setProperty("grid-template-columns", `repeat(${v},1fr)`); - }; - } else { - const currentHeight = parseInt(localStorage.getItem("rowHeight") || "48", 10); - sliderContainer.innerHTML = ` - - - ${currentHeight}px - `; - const rowSlider = document.getElementById("rowHeightSlider"); - const rowValue = document.getElementById("rowHeightValue"); - rowSlider.oninput = e => { - const v = e.target.value; - document.documentElement.style.setProperty("--file-row-height", v + "px"); - localStorage.setItem("rowHeight", v); - rowValue.textContent = v + "px"; - }; - } - } - - // 7) render files (only if still latest) - if (reqId !== __fileListReqSeq) return []; - - if (window.viewMode === "gallery") { - renderGalleryView(folder); - } else { - renderFileTable(folder); - } + fileListContainer.innerHTML = ` +
+ ${t("no_files_found")} +
+ ${t("no_folder_access_yet") || "No folder access has been assigned to your account yet."} +
+
`; + + const summaryElem = document.getElementById("fileSummary"); + if (summaryElem) summaryElem.style.display = "none"; + const sliderContainer = document.getElementById("viewSliderContainer"); + if (sliderContainer) sliderContainer.style.display = "none"; + + const strip = document.getElementById("folderStripContainer"); + if (strip) strip.style.display = "none"; + updateFileActionButtons(); fileListContainer.style.visibility = "visible"; - - // ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) ----- - try { - const foldersRes = await foldersPromise; - 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 --- - let subfolders = []; - const hidden = new Set(["profile_pics", "trash"]); - if (Array.isArray(folderRaw)) { - const allPaths = folderRaw.map(item => item.folder ?? item); - const depth = folder === "root" ? 1 : folder.split("/").length + 1; - subfolders = allPaths - .filter(p => { - if (folder === "root") return p.indexOf("/") === -1; - if (!p.startsWith(folder + "/")) return false; - return p.split("/").length === depth; - }) - .map(p => ({ name: p.split("/").pop(), full: p })); - } - subfolders = subfolders.filter(sf => !hidden.has(sf.name)); - - // inject folder strip below actions, above file list - let strip = document.getElementById("folderStripContainer"); - if (!strip) { - strip = document.createElement("div"); - strip.id = "folderStripContainer"; - strip.className = "folder-strip-container"; - actionsContainer.parentNode.insertBefore(strip, actionsContainer); - } - - if (window.showFoldersInList && subfolders.length) { - strip.innerHTML = subfolders.map(sf => ` -
- folder -
${escapeHTML(sf.name)}
-
- `).join(""); - strip.style.display = "flex"; - - // wire up each folder‐tile - strip.querySelectorAll(".folder-item").forEach(el => { - // 1) click to navigate - el.addEventListener("click", () => { - const dest = el.dataset.folder; - window.currentFolder = dest; - localStorage.setItem("lastOpenedFolder", dest); - updateBreadcrumbTitle(dest); - document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected")); - document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected"); - loadFileList(dest); - }); - - // 2) drag & drop - el.addEventListener("dragover", folderDragOverHandler); - el.addEventListener("dragleave", folderDragLeaveHandler); - el.addEventListener("drop", folderDropHandler); - - // 3) right-click context menu - el.addEventListener("contextmenu", e => { - e.preventDefault(); - e.stopPropagation(); - - const dest = el.dataset.folder; - window.currentFolder = dest; - localStorage.setItem("lastOpenedFolder", dest); - - // highlight the strip tile - strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected")); - el.classList.add("selected"); - - // reuse folderManager menu - const menuItems = [ - { - label: t("create_folder"), - action: () => document.getElementById("createFolderModal").style.display = "block" - }, - { - label: t("rename_folder"), - action: () => openRenameFolderModal() - }, - { - label: t("folder_share"), - action: () => openFolderShareModal(dest) - }, - { - label: t("delete_folder"), - action: () => openDeleteFolderModal() - } - ]; - showFolderManagerContextMenu(e.pageX, e.pageY, menuItems); - }); - }); - - // one global click to hide any open context menu - document.addEventListener("click", hideFolderManagerContextMenu); - - } else { - strip.style.display = "none"; - } - } catch { - // ignore folder errors; rows already rendered + // We still try to populate the folder strip below + } + + // 5) normalize files array + if (!Array.isArray(data.files)) { + data.files = Object.entries(data.files).map(([name, meta]) => { + meta.name = name; + return meta; + }); + } + + data.files = data.files.map(f => { + f.fullName = (f.path || f.name).trim().toLowerCase(); + + // Prefer numeric size if API provides it; otherwise parse the "1.2 MB" string + let bytes = Number.isFinite(f.sizeBytes) + ? f.sizeBytes + : parseSizeToBytes(String(f.size || "")); + + if (!Number.isFinite(bytes)) bytes = Infinity; + + f.editable = canEditFile(f.name) && (bytes <= MAX_EDIT_BYTES); + f.folder = folder; + return f; + }); + fileData = data.files; + + if (reqId !== __fileListReqSeq) return []; + + // 6) inject summary + slider + if (actionsContainer) { + // a) summary + let summaryElem = document.getElementById("fileSummary"); + if (!summaryElem) { + summaryElem = document.createElement("div"); + summaryElem.id = "fileSummary"; + summaryElem.style.cssText = "float:right; margin:0 60px 0 auto; font-size:0.9em;"; + actionsContainer.appendChild(summaryElem); } - - return data.files; - + summaryElem.style.display = "block"; + summaryElem.innerHTML = buildFolderSummary(fileData); + + // b) slider + const viewMode = window.viewMode || "table"; + let sliderContainer = document.getElementById("viewSliderContainer"); + if (!sliderContainer) { + sliderContainer = document.createElement("div"); + sliderContainer.id = "viewSliderContainer"; + sliderContainer.style.cssText = "display:inline-flex; align-items:center; margin-right:auto; font-size:0.9em;"; + actionsContainer.insertBefore(sliderContainer, summaryElem); + } else { + sliderContainer.style.display = "inline-flex"; + } + + if (viewMode === "gallery") { + const w = window.innerWidth; + let maxCols; + if (w < 600) maxCols = 1; + else if (w < 900) maxCols = 2; + else if (w < 1200) maxCols = 4; + else maxCols = 6; + + const currentCols = Math.min( + parseInt(localStorage.getItem("galleryColumns") || "3", 10), + maxCols + ); + + sliderContainer.innerHTML = ` + + + ${currentCols} + `; + const gallerySlider = document.getElementById("galleryColumnsSlider"); + const galleryValue = document.getElementById("galleryColumnsValue"); + gallerySlider.oninput = e => { + const v = +e.target.value; + localStorage.setItem("galleryColumns", v); + galleryValue.textContent = v; + document.querySelector(".gallery-container") + ?.style.setProperty("grid-template-columns", `repeat(${v},1fr)`); + }; + } else { + const currentHeight = parseInt(localStorage.getItem("rowHeight") || "48", 10); + sliderContainer.innerHTML = ` + + + ${currentHeight}px + `; + const rowSlider = document.getElementById("rowHeightSlider"); + const rowValue = document.getElementById("rowHeightValue"); + rowSlider.oninput = e => { + const v = e.target.value; + document.documentElement.style.setProperty("--file-row-height", v + "px"); + localStorage.setItem("rowHeight", v); + rowValue.textContent = v + "px"; + }; + } + } + + // 7) render files + if (reqId !== __fileListReqSeq) return []; + + if (window.viewMode === "gallery") { + renderGalleryView(folder); + } else { + renderFileTable(folder); + } + updateFileActionButtons(); + fileListContainer.style.visibility = "visible"; + + // ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) ----- + try { + const foldersRes = await foldersPromise; + // If folders API forbids, just skip the strip; keep file rows rendered + if (foldersRes.status === 403) { + const strip = document.getElementById("folderStripContainer"); + if (strip) strip.style.display = "none"; + return data.files; + } + + const folderRaw = await safeJson(foldersRes).catch(() => []); // don't block file render on strip issues + if (reqId !== __fileListReqSeq) return data.files; + + // --- build ONLY the *direct* children of current folder --- + let subfolders = []; + const hidden = new Set(["profile_pics", "trash"]); + if (Array.isArray(folderRaw)) { + const allPaths = folderRaw.map(item => item.folder ?? item); + const depth = folder === "root" ? 1 : folder.split("/").length + 1; + subfolders = allPaths + .filter(p => { + if (folder === "root") return p.indexOf("/") === -1; + if (!p.startsWith(folder + "/")) return false; + return p.split("/").length === depth; + }) + .map(p => ({ name: p.split("/").pop(), full: p })); + } + subfolders = subfolders.filter(sf => !hidden.has(sf.name)); + + let strip = document.getElementById("folderStripContainer"); + if (!strip) { + strip = document.createElement("div"); + strip.id = "folderStripContainer"; + strip.className = "folder-strip-container"; + actionsContainer.parentNode.insertBefore(strip, actionsContainer); + } + + if (window.showFoldersInList && subfolders.length) { + strip.innerHTML = subfolders.map(sf => ` +
+ folder +
${escapeHTML(sf.name)}
+
+ `).join(""); + strip.style.display = "flex"; + + strip.querySelectorAll(".folder-item").forEach(el => { + // 1) click to navigate + el.addEventListener("click", () => { + const dest = el.dataset.folder; + window.currentFolder = dest; + localStorage.setItem("lastOpenedFolder", dest); + updateBreadcrumbTitle(dest); + document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected")); + document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected"); + loadFileList(dest); + }); + + // 2) drag & drop + el.addEventListener("dragover", folderDragOverHandler); + el.addEventListener("dragleave", folderDragLeaveHandler); + el.addEventListener("drop", folderDropHandler); + + // 3) right-click context menu + el.addEventListener("contextmenu", e => { + e.preventDefault(); + e.stopPropagation(); + + const dest = el.dataset.folder; + window.currentFolder = dest; + localStorage.setItem("lastOpenedFolder", dest); + + strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected")); + el.classList.add("selected"); + + const menuItems = [ + { + label: t("create_folder"), + action: () => document.getElementById("createFolderModal").style.display = "block" + }, + { + label: t("rename_folder"), + action: () => openRenameFolderModal() + }, + { + label: t("folder_share"), + action: () => openFolderShareModal(dest) + }, + { + label: t("delete_folder"), + action: () => openDeleteFolderModal() + } + ]; + showFolderManagerContextMenu(e.pageX, e.pageY, menuItems); + }); + }); + + document.addEventListener("click", hideFolderManagerContextMenu); + + } else { + strip.style.display = "none"; + } + } catch { + // ignore folder errors; rows already rendered + } + + return data.files; + } catch (err) { - console.error("Error loading file list:", err); - if (err.message !== "Unauthorized") { - fileListContainer.textContent = "Error loading files."; - } - return []; + console.error("Error loading file list:", err); + if (err.status === 403) { + showToast(t("no_access_to_resource") || "You don't have access to this folder.", "error"); + const fileListContainer = document.getElementById("fileList"); + if (fileListContainer) fileListContainer.textContent = t("no_access_to_resource") || "You don't have access to this folder."; + } else if (err.message !== "Unauthorized") { + const fileListContainer = document.getElementById("fileList"); + if (fileListContainer) fileListContainer.textContent = "Error loading files."; + } + return []; } finally { - // Only the latest call should restore visibility - if (reqId === __fileListReqSeq) { - fileListContainer.style.visibility = "visible"; - } + if (reqId === __fileListReqSeq) { + fileListContainer.style.visibility = "visible"; + } } -} - -/** - * Update renderFileTable so it writes its content into the provided container. - */ -export function renderFileTable(folder, container, subfolders) { + } + + /** + * Render table view + */ + export function renderFileTable(folder, container, subfolders) { const fileListContent = container || document.getElementById("fileList"); const searchTerm = (window.currentSearchTerm || "").toLowerCase(); const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10); let currentPage = window.currentPage || 1; - - // Use Fuse.js search via our helper function. + const filteredFiles = searchFiles(searchTerm); - + const totalFiles = filteredFiles.length; const totalPages = Math.ceil(totalFiles / itemsPerPageSetting); if (currentPage > totalPages) { - currentPage = totalPages > 0 ? totalPages : 1; - window.currentPage = currentPage; + currentPage = totalPages > 0 ? totalPages : 1; + window.currentPage = currentPage; } - const folderPath = folder === "root" - ? "uploads/" - : "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/"; - - // Build the top controls and append the advanced search toggle button. + + // We pass a harmless "base" string to keep buildFileTableRow happy, + // then we will FIX the preview/thumbnail URLs to the API below. + const fakeBase = "#/"; + const topControlsHTML = buildSearchAndPaginationControls({ - currentPage, - totalPages, - searchTerm: window.currentSearchTerm || "" + currentPage, + totalPages, + searchTerm: window.currentSearchTerm || "" }); - + const combinedTopHTML = topControlsHTML; - + let headerHTML = buildFileTableHeader(sortOrder); const startIndex = (currentPage - 1) * itemsPerPageSetting; const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles); let rowsHTML = ""; if (totalFiles > 0) { - filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => { - let rowHTML = buildFileTableRow(file, folderPath); - rowHTML = rowHTML.replace(" 0) { - tagBadgesHTML = '
'; - file.tags.forEach(tag => { - tagBadgesHTML += `${escapeHTML(tag.name)}`; - }); - tagBadgesHTML += "
"; - } - rowHTML = rowHTML.replace(/()(.*?)(<\/td>)/, (match, p1, p2, p3) => { - return p1 + p2 + tagBadgesHTML + p3; - }); - rowsHTML += rowHTML; + filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => { + // Build row with a neutral base, then correct the links/preview below. + let rowHTML = buildFileTableRow(file, fakeBase); + // Give the row an ID so we can patch attributes safely + rowHTML = rowHTML.replace(" 0) { + tagBadgesHTML = '
'; + file.tags.forEach(tag => { + tagBadgesHTML += `${escapeHTML(tag.name)}`; + }); + tagBadgesHTML += "
"; + } + rowsHTML += rowHTML.replace(/()(.*?)(<\/td>)/, (match, p1, p2, p3) => { + return p1 + p2 + tagBadgesHTML + p3; }); + }); } else { - rowsHTML += `No files found.`; + rowsHTML += `No files found.`; } rowsHTML += ""; const bottomControlsHTML = buildBottomControls(itemsPerPageSetting); - + fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML; - + + // PATCH each row's preview/thumb to use the secure API URLs + if (totalFiles > 0) { + filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => { + const rowEl = document.getElementById(`file-row-${encodeURIComponent(file.name)}-${startIndex + idx}`); + if (!rowEl) return; + + const previewUrl = apiFileUrl(file.folder || folder, file.name, true); + + // Preview button dataset + const previewBtn = rowEl.querySelector(".preview-btn"); + if (previewBtn) { + previewBtn.dataset.previewUrl = previewUrl; + previewBtn.dataset.previewName = file.name; + } + + // Thumbnail (if present) + const thumbImg = rowEl.querySelector("img"); + if (thumbImg) { + thumbImg.src = previewUrl; + thumbImg.setAttribute("data-cache-key", previewUrl); + } + + // Any anchor that might have been built to point at a file path + rowEl.querySelectorAll('a[href]').forEach(a => { + // Only rewrite obvious file anchors (ignore actions with '#', 'javascript:', etc.) + if (/^#|^javascript:/i.test(a.getAttribute('href') || '')) return; + a.href = previewUrl; + }); + }); + } + fileListContent.querySelectorAll('.folder-item').forEach(el => { - el.addEventListener('click', () => loadFileList(el.dataset.folder)); + el.addEventListener('click', () => loadFileList(el.dataset.folder)); }); - + // pagination clicks const prevBtn = document.getElementById("prevPageBtn"); if (prevBtn) prevBtn.addEventListener("click", () => { - if (window.currentPage > 1) { - window.currentPage--; - renderFileTable(folder, container); - } + if (window.currentPage > 1) { + window.currentPage--; + renderFileTable(folder, container); + } }); const nextBtn = document.getElementById("nextPageBtn"); if (nextBtn) nextBtn.addEventListener("click", () => { - // totalPages is computed above in this scope - if (window.currentPage < totalPages) { - window.currentPage++; - renderFileTable(folder, container); - } + if (window.currentPage < totalPages) { + window.currentPage++; + renderFileTable(folder, container); + } }); - - // ADD: advanced search toggle + + // advanced search toggle const advToggle = document.getElementById("advancedSearchToggle"); if (advToggle) advToggle.addEventListener("click", () => { - toggleAdvancedSearch(); + toggleAdvancedSearch(); }); - + // items-per-page selector const itemsSelect = document.getElementById("itemsPerPageSelect"); if (itemsSelect) itemsSelect.addEventListener("change", e => { - window.itemsPerPage = parseInt(e.target.value, 10); - localStorage.setItem("itemsPerPage", window.itemsPerPage); - window.currentPage = 1; - renderFileTable(folder, container); + window.itemsPerPage = parseInt(e.target.value, 10); + localStorage.setItem("itemsPerPage", window.itemsPerPage); + window.currentPage = 1; + renderFileTable(folder, container); }); - - // hook up the master checkbox - const selectAll = document.getElementById("selectAll"); - if (selectAll) { - selectAll.addEventListener("change", () => { - toggleAllCheckboxes(selectAll); - }); - } - - // 1) Row-click selects the row + + // Row-select fileListContent.querySelectorAll("tbody tr").forEach(row => { - row.addEventListener("click", e => { - // grab the underlying checkbox value - const cb = row.querySelector(".file-checkbox"); - if (!cb) return; - toggleRowSelection(e, cb.value); - }); + row.addEventListener("click", e => { + const cb = row.querySelector(".file-checkbox"); + if (!cb) return; + toggleRowSelection(e, cb.value); + }); }); - - // 2) Download buttons + + // Download buttons fileListContent.querySelectorAll(".download-btn").forEach(btn => { - btn.addEventListener("click", e => { - e.stopPropagation(); - openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder); - }); + btn.addEventListener("click", e => { + e.stopPropagation(); + openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder); + }); }); - - // 3) Edit buttons + + // Edit buttons fileListContent.querySelectorAll(".edit-btn").forEach(btn => { - btn.addEventListener("click", e => { - e.stopPropagation(); - editFile(btn.dataset.editName, btn.dataset.editFolder); - }); + btn.addEventListener("click", async e => { + e.stopPropagation(); + const m = await import('./fileEditor.js'); + m.editFile(btn.dataset.editName, btn.dataset.editFolder); + }); }); - - // 4) Rename buttons + + // Rename buttons fileListContent.querySelectorAll(".rename-btn").forEach(btn => { - btn.addEventListener("click", e => { - e.stopPropagation(); - renameFile(btn.dataset.renameName, btn.dataset.renameFolder); - }); + btn.addEventListener("click", async e => { + e.stopPropagation(); + const m = await import('./fileActions.js'); + m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder); + }); }); - - // 5) Preview buttons + + // Preview buttons fileListContent.querySelectorAll(".preview-btn").forEach(btn => { - btn.addEventListener("click", e => { - e.stopPropagation(); - previewFile(btn.dataset.previewUrl, btn.dataset.previewName); - }); + btn.addEventListener("click", async e => { + e.stopPropagation(); + const m = await import('./filePreview.js'); + m.previewFile(btn.dataset.previewUrl, btn.dataset.previewName); + }); }); - + createViewToggleButton(); - - // Setup event listeners. + + // search input const newSearchInput = document.getElementById("searchInput"); if (newSearchInput) { - newSearchInput.addEventListener("input", debounce(function () { - window.currentSearchTerm = newSearchInput.value; - window.currentPage = 1; - renderFileTable(folder, container); - setTimeout(() => { - const freshInput = document.getElementById("searchInput"); - if (freshInput) { - freshInput.focus(); - const len = freshInput.value.length; - freshInput.setSelectionRange(len, len); - } - }, 0); - }, 300)); + newSearchInput.addEventListener("input", debounce(function () { + window.currentSearchTerm = newSearchInput.value; + window.currentPage = 1; + renderFileTable(folder, container); + setTimeout(() => { + const freshInput = document.getElementById("searchInput"); + if (freshInput) { + freshInput.focus(); + const len = freshInput.value.length; + freshInput.setSelectionRange(len, len); + } + }, 0); + }, 300)); } + const slider = document.getElementById('rowHeightSlider'); const valueDisplay = document.getElementById('rowHeightValue'); if (slider) { - slider.addEventListener('input', e => { - const v = +e.target.value; // slider value in px - document.documentElement.style.setProperty('--file-row-height', v + 'px'); - localStorage.setItem('rowHeight', v); - valueDisplay.textContent = v + 'px'; - }); + slider.addEventListener('input', e => { + const v = +e.target.value; // slider value in px + document.documentElement.style.setProperty('--file-row-height', v + 'px'); + localStorage.setItem('rowHeight', v); + valueDisplay.textContent = v + 'px'; + }); } - + document.querySelectorAll("table.table thead th[data-column]").forEach(cell => { - cell.addEventListener("click", function () { - const column = this.getAttribute("data-column"); - sortFiles(column, folder); - }); + cell.addEventListener("click", function () { + const column = this.getAttribute("data-column"); + sortFiles(column, folder); + }); }); document.querySelectorAll("#fileList .file-checkbox").forEach(checkbox => { - checkbox.addEventListener("change", function (e) { - updateRowHighlight(e.target); - updateFileActionButtons(); - }); + checkbox.addEventListener("change", function (e) { + updateRowHighlight(e.target); + updateFileActionButtons(); + }); }); document.querySelectorAll(".share-btn").forEach(btn => { - btn.addEventListener("click", function (e) { - e.stopPropagation(); - const fileName = this.getAttribute("data-file"); - const file = fileData.find(f => f.name === fileName); - if (file) { - import('./filePreview.js').then(module => { - module.openShareModal(file, folder); - }); - } - }); + btn.addEventListener("click", function (e) { + e.stopPropagation(); + const fileName = this.getAttribute("data-file"); + const file = fileData.find(f => f.name === fileName); + if (file) { + import('./filePreview.js').then(module => { + module.openShareModal(file, folder); + }); + } + }); }); updateFileActionButtons(); document.querySelectorAll("#fileList tbody tr").forEach(row => { - row.setAttribute("draggable", "true"); - import('./fileDragDrop.js').then(module => { - row.addEventListener("dragstart", module.fileDragStartHandler); - }); + row.setAttribute("draggable", "true"); + import('./fileDragDrop.js').then(module => { + row.addEventListener("dragstart", module.fileDragStartHandler); + }); }); document.querySelectorAll(".download-btn, .edit-btn, .rename-btn").forEach(btn => { - btn.addEventListener("click", e => e.stopPropagation()); + btn.addEventListener("click", e => e.stopPropagation()); }); bindFileListContextMenu(); -} - -// A helper to compute the max image height based on the current column count. -function getMaxImageHeight() { + } + + // A helper to compute the max image height based on the current column count. + function getMaxImageHeight() { const columns = parseInt(window.galleryColumns || 3, 10); - return 150 * (7 - columns); // adjust the multiplier as needed. -} - -export function renderGalleryView(folder, container) { + return 150 * (7 - columns); + } + + export function renderGalleryView(folder, container) { const fileListContent = container || document.getElementById("fileList"); const searchTerm = (window.currentSearchTerm || "").toLowerCase(); const filteredFiles = searchFiles(searchTerm); - const folderPath = folder === "root" - ? "uploads/" - : "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/"; - + + // API preview base (we’ll build per-file URLs) + const apiBase = `/api/file/download.php?folder=${encodeURIComponent(folder)}&file=`; + // pagination settings const itemsPerPage = window.itemsPerPage; let currentPage = window.currentPage || 1; const totalFiles = filteredFiles.length; const totalPages = Math.ceil(totalFiles / itemsPerPage); if (currentPage > totalPages) { - currentPage = totalPages || 1; - window.currentPage = currentPage; + currentPage = totalPages || 1; + window.currentPage = currentPage; } - + // --- Top controls: search + pagination + items-per-page --- let galleryHTML = buildSearchAndPaginationControls({ - currentPage, - totalPages, - searchTerm: window.currentSearchTerm || "" + currentPage, + totalPages, + searchTerm: window.currentSearchTerm || "" }); - + // wire up search input just like table view setTimeout(() => { - const searchInput = document.getElementById("searchInput"); - if (searchInput) { - searchInput.addEventListener("input", debounce(() => { - window.currentSearchTerm = searchInput.value; - window.currentPage = 1; - renderGalleryView(folder); - // keep caret at end - setTimeout(() => { - const f = document.getElementById("searchInput"); - if (f) { - f.focus(); - const len = f.value.length; - f.setSelectionRange(len, len); - } - }, 0); - }, 300)); - } + const searchInput = document.getElementById("searchInput"); + if (searchInput) { + searchInput.addEventListener("input", debounce(() => { + window.currentSearchTerm = searchInput.value; + window.currentPage = 1; + renderGalleryView(folder); + setTimeout(() => { + const f = document.getElementById("searchInput"); + if (f) { + f.focus(); + const len = f.value.length; + f.setSelectionRange(len, len); + } + }, 0); + }, 300)); + } }, 0); - - // --- Column slider with responsive max --- + + // determine column max by screen size const numColumns = window.galleryColumns || 3; - // clamp slider max to 1 on small (<600px), 2 on medium (<900px), else up to 6 const w = window.innerWidth; let maxCols = 6; if (w < 600) maxCols = 1; else if (w < 900) maxCols = 2; - - // ensure current value doesn’t exceed the new max const startCols = Math.min(numColumns, maxCols); window.galleryColumns = startCols; - + // --- Start gallery grid --- galleryHTML += ` `; }); - + galleryHTML += ``; // end gallery-container - + // bottom controls galleryHTML += buildBottomControls(itemsPerPage); - + // render fileListContent.innerHTML = galleryHTML; - - // --- Now wire up all behaviors without inline handlers --- - - // ADD: pagination buttons for gallery + + // pagination buttons for gallery const prevBtn = document.getElementById("prevPageBtn"); if (prevBtn) prevBtn.addEventListener("click", () => { - if (window.currentPage > 1) { - window.currentPage--; - renderGalleryView(folder, container); - } + if (window.currentPage > 1) { + window.currentPage--; + renderGalleryView(folder, container); + } }); const nextBtn = document.getElementById("nextPageBtn"); if (nextBtn) nextBtn.addEventListener("click", () => { - if (window.currentPage < totalPages) { - window.currentPage++; - renderGalleryView(folder, container); - } + if (window.currentPage < totalPages) { + window.currentPage++; + renderGalleryView(folder, container); + } }); - - // ←— ADD: advanced search toggle + + // advanced search toggle const advToggle = document.getElementById("advancedSearchToggle"); if (advToggle) advToggle.addEventListener("click", () => { - toggleAdvancedSearch(); + toggleAdvancedSearch(); }); - - // ←— ADD: wire up context-menu in gallery + + // context menu in gallery bindFileListContextMenu(); - - // ADD: items-per-page selector for gallery + + // items-per-page selector for gallery const itemsSelect = document.getElementById("itemsPerPageSelect"); if (itemsSelect) itemsSelect.addEventListener("change", e => { - window.itemsPerPage = parseInt(e.target.value, 10); - localStorage.setItem("itemsPerPage", window.itemsPerPage); - window.currentPage = 1; - renderGalleryView(folder, container); + window.itemsPerPage = parseInt(e.target.value, 10); + localStorage.setItem("itemsPerPage", window.itemsPerPage); + window.currentPage = 1; + renderGalleryView(folder, container); }); - + // cache images on load fileListContent.querySelectorAll('.gallery-thumbnail').forEach(img => { - const key = img.dataset.cacheKey; - img.addEventListener('load', () => cacheImage(img, key)); + const key = img.dataset.cacheKey; + img.addEventListener('load', () => cacheImage(img, key)); }); - - // preview clicks + + // preview clicks (dynamic import to avoid global dependency) fileListContent.querySelectorAll(".gallery-preview").forEach(el => { - el.addEventListener("click", () => { - previewFile(el.dataset.previewUrl, el.dataset.previewName); - }); + el.addEventListener("click", async () => { + const m = await import('./filePreview.js'); + m.previewFile(el.dataset.previewUrl, el.dataset.previewName); + }); }); - + // download clicks fileListContent.querySelectorAll(".download-btn").forEach(btn => { - btn.addEventListener("click", e => { - e.stopPropagation(); - openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder); - }); + btn.addEventListener("click", e => { + e.stopPropagation(); + openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder); + }); }); - + // edit clicks fileListContent.querySelectorAll(".edit-btn").forEach(btn => { - btn.addEventListener("click", e => { - e.stopPropagation(); - editFile(btn.dataset.editName, btn.dataset.editFolder); - }); + btn.addEventListener("click", async e => { + e.stopPropagation(); + const m = await import('./fileEditor.js'); + m.editFile(btn.dataset.editName, btn.dataset.editFolder); + }); }); - + // rename clicks fileListContent.querySelectorAll(".rename-btn").forEach(btn => { - btn.addEventListener("click", e => { - e.stopPropagation(); - renameFile(btn.dataset.renameName, btn.dataset.renameFolder); - }); + btn.addEventListener("click", async e => { + e.stopPropagation(); + const m = await import('./fileActions.js'); + m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder); + }); }); - + // share clicks fileListContent.querySelectorAll(".share-btn").forEach(btn => { - btn.addEventListener("click", e => { - e.stopPropagation(); - const fileName = btn.dataset.file; - const fileObj = fileData.find(f => f.name === fileName); - if (fileObj) { - import('./filePreview.js').then(m => m.openShareModal(fileObj, folder)); - } - }); + btn.addEventListener("click", e => { + e.stopPropagation(); + const fileName = btn.dataset.file; + const fileObj = fileData.find(f => f.name === fileName); + if (fileObj) { + import('./filePreview.js').then(m => m.openShareModal(fileObj, folder)); + } + }); }); - + // checkboxes fileListContent.querySelectorAll(".file-checkbox").forEach(cb => { - cb.addEventListener("change", () => updateFileActionButtons()); + cb.addEventListener("change", () => updateFileActionButtons()); }); - + // slider const slider = document.getElementById("galleryColumnsSlider"); if (slider) { - slider.addEventListener("input", () => { - const v = +slider.value; - document.getElementById("galleryColumnsValue").textContent = v; - window.galleryColumns = v; - document.querySelector(".gallery-container") - .style.gridTemplateColumns = `repeat(${v},1fr)`; - document.querySelectorAll(".gallery-thumbnail") - .forEach(img => img.style.maxHeight = getMaxImageHeight() + "px"); - }); + slider.addEventListener("input", () => { + const v = +slider.value; + document.getElementById("galleryColumnsValue").textContent = v; + window.galleryColumns = v; + document.querySelector(".gallery-container") + .style.gridTemplateColumns = `repeat(${v},1fr)`; + document.querySelectorAll(".gallery-thumbnail") + .forEach(img => img.style.maxHeight = getMaxImageHeight() + "px"); + }); } - - // pagination functions + + // pagination helpers window.changePage = newPage => { - window.currentPage = newPage; - if (window.viewMode === "gallery") renderGalleryView(folder); - else renderFileTable(folder); + window.currentPage = newPage; + if (window.viewMode === "gallery") renderGalleryView(folder); + else renderFileTable(folder); }; - + window.changeItemsPerPage = cnt => { - window.itemsPerPage = +cnt; - localStorage.setItem("itemsPerPage", cnt); - window.currentPage = 1; - if (window.viewMode === "gallery") renderGalleryView(folder); - else renderFileTable(folder); + window.itemsPerPage = +cnt; + localStorage.setItem("itemsPerPage", cnt); + window.currentPage = 1; + if (window.viewMode === "gallery") renderGalleryView(folder); + else renderFileTable(folder); }; - - // update toolbar and toggle button + updateFileActionButtons(); createViewToggleButton(); -} - -// Responsive slider constraints based on screen size. -function updateSliderConstraints() { + } + + // Responsive slider constraints based on screen size. + function updateSliderConstraints() { const slider = document.getElementById("galleryColumnsSlider"); if (!slider) return; - + const width = window.innerWidth; let min = 1; let max; - - // Set maximum based on screen size. - if (width < 600) { // small devices (phones) - max = 1; - } else if (width < 1024) { // medium devices - max = 3; - } else if (width < 1440) { // between medium and large devices - max = 4; - } else { // large devices and above - max = 6; + + if (width < 600) { + max = 1; + } else if (width < 1024) { + max = 3; + } else if (width < 1440) { + max = 4; + } else { + max = 6; } - - // Adjust the slider's current value if needed + let currentVal = parseInt(slider.value, 10); if (currentVal > max) { - currentVal = max; - slider.value = max; + currentVal = max; + slider.value = max; } - + slider.min = min; slider.max = max; document.getElementById("galleryColumnsValue").textContent = currentVal; - - // Update the grid layout based on the current slider value. + const galleryContainer = document.querySelector(".gallery-container"); if (galleryContainer) { - galleryContainer.style.gridTemplateColumns = `repeat(${currentVal}, 1fr)`; + galleryContainer.style.gridTemplateColumns = `repeat(${currentVal}, 1fr)`; } -} - -window.addEventListener('load', updateSliderConstraints); -window.addEventListener('resize', updateSliderConstraints); - -export function sortFiles(column, folder) { + } + + window.addEventListener('load', updateSliderConstraints); + window.addEventListener('resize', updateSliderConstraints); + + export function sortFiles(column, folder) { if (sortOrder.column === column) { - sortOrder.ascending = !sortOrder.ascending; + sortOrder.ascending = !sortOrder.ascending; } else { - sortOrder.column = column; - sortOrder.ascending = true; + sortOrder.column = column; + sortOrder.ascending = true; } fileData.sort((a, b) => { - let valA = a[column] || ""; - let valB = b[column] || ""; - if (column === "modified" || column === "uploaded") { - const parsedA = parseCustomDate(valA); - const parsedB = parseCustomDate(valB); - valA = parsedA; - valB = parsedB; - } else if (typeof valA === "string") { - valA = valA.toLowerCase(); - valB = valB.toLowerCase(); - } - if (valA < valB) return sortOrder.ascending ? -1 : 1; - if (valA > valB) return sortOrder.ascending ? 1 : -1; - return 0; + let valA = a[column] || ""; + let valB = b[column] || ""; + if (column === "modified" || column === "uploaded") { + const parsedA = parseCustomDate(valA); + const parsedB = parseCustomDate(valB); + valA = parsedA; + valB = parsedB; + } else if (typeof valA === "string") { + valA = valA.toLowerCase(); + valB = valB.toLowerCase(); + } + if (valA < valB) return sortOrder.ascending ? -1 : 1; + if (valA > valB) return sortOrder.ascending ? 1 : -1; + return 0; }); if (window.viewMode === "gallery") { - renderGalleryView(folder); + renderGalleryView(folder); } else { - renderFileTable(folder); + renderFileTable(folder); } -} - -function parseCustomDate(dateStr) { + } + + function parseCustomDate(dateStr) { dateStr = dateStr.replace(/\s+/g, " ").trim(); const parts = dateStr.split(" "); if (parts.length !== 2) { - return new Date(dateStr).getTime(); + return new Date(dateStr).getTime(); } const datePart = parts[0]; const timePart = parts[1]; const dateComponents = datePart.split("/"); if (dateComponents.length !== 3) { - return new Date(dateStr).getTime(); + return new Date(dateStr).getTime(); } let month = parseInt(dateComponents[0], 10); let day = parseInt(dateComponents[1], 10); let year = parseInt(dateComponents[2], 10); if (year < 100) { - year += 2000; + year += 2000; } const timeRegex = /^(\d{1,2}):(\d{2})(AM|PM)$/i; const match = timePart.match(timeRegex); if (!match) { - return new Date(dateStr).getTime(); + return new Date(dateStr).getTime(); } let hour = parseInt(match[1], 10); const minute = parseInt(match[2], 10); const period = match[3].toUpperCase(); if (period === "PM" && hour !== 12) { - hour += 12; + hour += 12; } if (period === "AM" && hour === 12) { - hour = 0; + hour = 0; } return new Date(year, month - 1, day, hour, minute).getTime(); -} - -export function canEditFile(fileName) { + } + + export function canEditFile(fileName) { if (!fileName || typeof fileName !== "string") return false; const dot = fileName.lastIndexOf("."); if (dot < 0) return false; - + const ext = fileName.slice(dot + 1).toLowerCase(); - - // Text/code-only. Intentionally exclude php/phtml/phar/etc. + const allowedExtensions = [ - // Plain text & docs (text) - "txt", "text", "md", "markdown", "rst", - - // Web - "html", "htm", "xhtml", "shtml", - "css", "scss", "sass", "less", - - // JS/TS - "js", "mjs", "cjs", "jsx", - "ts", "tsx", - - // Data & config formats - "json", "jsonc", "ndjson", - "yml", "yaml", "toml", "xml", "plist", - "ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc", - "env", "dotenv", - "csv", "tsv", "tab", - "log", - - // Shell / scripts - "sh", "bash", "zsh", "ksh", "fish", - "bat", "cmd", - "ps1", "psm1", "psd1", - - // Languages - "py", "pyw", // Python - "rb", // Ruby - "pl", "pm", // Perl - "go", // Go - "rs", // Rust - "java", // Java - "kt", "kts", // Kotlin - "scala", "sc", // Scala - "groovy", "gradle", // Groovy/Gradle - "c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx", // C/C++ - "m", "mm", // Obj-C / Obj-C++ - "swift", // Swift - "cs", "fs", "fsx", // C#, F# - "dart", - "lua", - "r", "rmd", - - // SQL - "sql", - - // Front-end SFC/templates - "vue", "svelte", - "twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade" + "txt", "text", "md", "markdown", "rst", + "html", "htm", "xhtml", "shtml", + "css", "scss", "sass", "less", + "js", "mjs", "cjs", "jsx", + "ts", "tsx", + "json", "jsonc", "ndjson", + "yml", "yaml", "toml", "xml", "plist", + "ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc", + "env", "dotenv", + "csv", "tsv", "tab", + "log", + "sh", "bash", "zsh", "ksh", "fish", + "bat", "cmd", + "ps1", "psm1", "psd1", + "py", "pyw", + "rb", + "pl", "pm", + "go", + "rs", + "java", + "kt", "kts", + "scala", "sc", + "groovy", "gradle", + "c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx", + "m", "mm", + "swift", + "cs", "fs", "fsx", + "dart", + "lua", + "r", "rmd", + "sql", + "vue", "svelte", + "twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade" ]; - + return allowedExtensions.includes(ext); -} - -// Expose global functions for pagination and preview. -window.changePage = function (newPage) { + } + + // Expose global functions for pagination and preview. + window.changePage = function (newPage) { window.currentPage = newPage; if (window.viewMode === 'gallery') { - renderGalleryView(window.currentFolder); + renderGalleryView(window.currentFolder); } else { - renderFileTable(window.currentFolder); + renderFileTable(window.currentFolder); } -}; - -window.changeItemsPerPage = function (newCount) { + }; + + window.changeItemsPerPage = function (newCount) { window.itemsPerPage = parseInt(newCount, 10); localStorage.setItem('itemsPerPage', newCount); window.currentPage = 1; if (window.viewMode === 'gallery') { - renderGalleryView(window.currentFolder); + renderGalleryView(window.currentFolder); } else { - renderFileTable(window.currentFolder); + renderFileTable(window.currentFolder); } -}; - -// fileListView.js (bottom) -window.loadFileList = loadFileList; -window.renderFileTable = renderFileTable; -window.renderGalleryView = renderGalleryView; -window.sortFiles = sortFiles; -window.toggleAdvancedSearch = toggleAdvancedSearch; \ No newline at end of file + }; + + // fileListView.js (bottom) + window.loadFileList = loadFileList; + window.renderFileTable = renderFileTable; + window.renderGalleryView = renderGalleryView; + window.sortFiles = sortFiles; + window.toggleAdvancedSearch = toggleAdvancedSearch; \ No newline at end of file diff --git a/public/js/folderManager.js b/public/js/folderManager.js index 1a1da8d..c3d98ad 100644 --- a/public/js/folderManager.js +++ b/public/js/folderManager.js @@ -7,6 +7,28 @@ import { openFolderShareModal } from './folderShareModal.js'; import { fetchWithCsrf } from './auth.js'; import { loadCsrfToken } from './main.js'; +/* ---------------------- + Helpers: safe JSON + state +----------------------*/ + +// Robust JSON reader that surfaces server errors (with status) +async function safeJson(res) { + const text = await res.text(); + let body = null; + try { body = text ? JSON.parse(text) : null; } catch { /* ignore */ } + + if (!res.ok) { + const msg = + (body && (body.error || body.message)) || + (text && text.trim()) || + `HTTP ${res.status}`; + const err = new Error(msg); + err.status = res.status; + throw err; + } + return body ?? {}; +} + /* ---------------------- Helper Functions (Data/State) ----------------------*/ @@ -15,7 +37,7 @@ import { loadCsrfToken } from './main.js'; export function formatFolderName(folder) { if (typeof folder !== "string") return ""; if (folder.indexOf("/") !== -1) { - let parts = folder.split("/"); + const parts = folder.split("/"); let indent = ""; for (let i = 1; i < parts.length; i++) { indent += "\u00A0\u00A0\u00A0\u00A0"; // 4 non-breaking spaces per level @@ -34,9 +56,7 @@ function buildFolderTree(folders) { const parts = folderPath.split('/'); let current = tree; parts.forEach(part => { - if (!current[part]) { - current[part] = {}; - } + if (!current[part]) current[part] = {}; current = current[part]; }); }); @@ -66,23 +86,29 @@ export function getParentFolder(folder) { Breadcrumb Functions ----------------------*/ -function renderBreadcrumb(normalizedFolder) { - if (!normalizedFolder || normalizedFolder === "") return ""; - const parts = normalizedFolder.split("/"); - let breadcrumbItems = []; - // Use the first segment as the root. - breadcrumbItems.push(`${escapeHTML(parts[0])}`); - let cumulative = parts[0]; - parts.slice(1).forEach(part => { - cumulative += "/" + part; - breadcrumbItems.push(` / `); - breadcrumbItems.push(`${escapeHTML(part)}`); - }); - return breadcrumbItems.join(''); +async function applyFolderCapabilities(folder) { + try { + const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' }); + if (!res.ok) return; + const caps = await res.json(); + + // top buttons + const createBtn = document.getElementById('createFolderBtn'); + const renameBtn = document.getElementById('renameFolderBtn'); + const deleteBtn = document.getElementById('deleteFolderBtn'); + const shareBtn = document.getElementById('shareFolderBtn'); + + if (createBtn) createBtn.disabled = !caps.canCreate; + if (renameBtn) renameBtn.disabled = !caps.canRename || folder === 'root'; + if (deleteBtn) deleteBtn.disabled = !caps.canDelete || folder === 'root'; + if (shareBtn) shareBtn.disabled = !caps.canShare || folder === 'root'; + + // keep for later if you want context menu to reflect caps + window.currentFolderCaps = caps; + } catch {} } -// --- NEW: Breadcrumb Delegation Setup --- -// bindBreadcrumbEvents(); removed in favor of delegation +// --- Breadcrumb Delegation Setup --- export function setupBreadcrumbDelegation() { const container = document.getElementById("fileListTitle"); if (!container) { @@ -104,7 +130,6 @@ export function setupBreadcrumbDelegation() { // Click handler via delegation function breadcrumbClickHandler(e) { - // find the nearest .breadcrumb-link const link = e.target.closest(".breadcrumb-link"); if (!link) return; @@ -115,12 +140,10 @@ function breadcrumbClickHandler(e) { window.currentFolder = folder; localStorage.setItem("lastOpenedFolder", folder); - // rebuild the title safely updateBreadcrumbTitle(folder); + applyFolderCapabilities(folder); expandTreePath(folder); - document.querySelectorAll(".folder-option").forEach(el => - el.classList.remove("selected") - ); + document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected")); const target = document.querySelector(`.folder-option[data-folder="${folder}"]`); if (target) target.classList.add("selected"); @@ -158,20 +181,18 @@ function breadcrumbDropHandler(e) { } const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []); if (filesToMove.length === 0) return; - fetch("/api/file/moveFiles.php", { + + fetchWithCsrf("/api/file/moveFiles.php", { method: "POST", + headers: { "Content-Type": "application/json" }, credentials: "include", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content") - }, body: JSON.stringify({ source: dragData.sourceFolder, files: filesToMove, destination: dropFolder }) }) - .then(response => response.json()) + .then(safeJson) .then(data => { if (data.success) { showToast(`File(s) moved successfully to ${dropFolder}!`); @@ -186,47 +207,39 @@ function breadcrumbDropHandler(e) { }); } - /* ---------------------- Check Current User's Folder-Only Permission ----------------------*/ -// This function uses localStorage values (set during login) to determine if the current user is restricted. -// If folderOnly is "true", then the personal folder (i.e. username) is forced as the effective root. -function checkUserFolderPermission() { - const username = localStorage.getItem("username"); - console.log("checkUserFolderPermission: username =", username); - if (!username) { - console.warn("No username in localStorage; skipping getUserPermissions fetch."); - return Promise.resolve(false); - } - if (localStorage.getItem("folderOnly") === "true") { - window.userFolderOnly = true; - console.log("checkUserFolderPermission: using localStorage.folderOnly = true"); - localStorage.setItem("lastOpenedFolder", username); - window.currentFolder = username; - return Promise.resolve(true); - } - return fetch("/api/getUserPermissions.php", { credentials: "include" }) - .then(response => response.json()) - .then(permissionsData => { - console.log("checkUserFolderPermission: permissionsData =", permissionsData); - if (permissionsData && permissionsData[username] && permissionsData[username].folderOnly) { - window.userFolderOnly = true; - localStorage.setItem("folderOnly", "true"); - localStorage.setItem("lastOpenedFolder", username); - window.currentFolder = username; - return true; - } else { - window.userFolderOnly = false; - localStorage.setItem("folderOnly", "false"); - return false; - } - }) - .catch(err => { - console.error("Error fetching user permissions:", err); - window.userFolderOnly = false; - return false; +// Authoritatively determine from the server; still write to localStorage for UI, +// but ignore any preexisting localStorage override for security. +async function checkUserFolderPermission() { + const username = localStorage.getItem("username") || ""; + try { + const res = await fetchWithCsrf("/api/getUserPermissions.php", { + method: "GET", + credentials: "include" }); + const permissionsData = await safeJson(res); + + const isFolderOnly = + !!(permissionsData && + permissionsData[username] && + permissionsData[username].folderOnly); + + window.userFolderOnly = isFolderOnly; + localStorage.setItem("folderOnly", isFolderOnly ? "true" : "false"); + + if (isFolderOnly && username) { + localStorage.setItem("lastOpenedFolder", username); + window.currentFolder = username; + } + return isFolderOnly; + } catch (err) { + console.error("Error fetching user permissions:", err); + window.userFolderOnly = false; + localStorage.setItem("folderOnly", "false"); + return false; + } } /* ---------------------- @@ -273,7 +286,7 @@ function expandTreePath(path) { const toggle = li.querySelector(".folder-toggle"); if (toggle) { toggle.innerHTML = "[" + '-' + "]"; - let state = loadFolderTreeState(); + const state = loadFolderTreeState(); state[cumulative] = "block"; saveFolderTreeState(state); } @@ -307,20 +320,18 @@ function folderDropHandler(event) { } const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []); if (filesToMove.length === 0) return; - fetch("/api/file/moveFiles.php", { + + fetchWithCsrf("/api/file/moveFiles.php", { method: "POST", + headers: { "Content-Type": "application/json" }, credentials: "include", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content") - }, body: JSON.stringify({ source: dragData.sourceFolder, files: filesToMove, destination: dropFolder }) }) - .then(response => response.json()) + .then(safeJson) .then(data => { if (data.success) { showToast(`File(s) moved successfully to ${dropFolder}!`); @@ -338,7 +349,7 @@ function folderDropHandler(event) { /* ---------------------- Main Folder Tree Rendering and Event Binding ----------------------*/ -// --- Helpers for safe breadcrumb rendering --- +// Safe breadcrumb DOM builder function renderBreadcrumbFragment(folderPath) { const frag = document.createDocumentFragment(); const parts = folderPath.split("/"); @@ -363,49 +374,52 @@ function renderBreadcrumbFragment(folderPath) { export function updateBreadcrumbTitle(folder) { const titleEl = document.getElementById("fileListTitle"); + if (!titleEl) return; titleEl.textContent = ""; titleEl.appendChild(document.createTextNode(t("files_in") + " (")); titleEl.appendChild(renderBreadcrumbFragment(folder)); titleEl.appendChild(document.createTextNode(")")); setupBreadcrumbDelegation(); + // Ensure context menu delegation is hooked to the dynamic breadcrumb container + bindFolderManagerContextMenu(); } export async function loadFolderTree(selectedFolder) { try { - // Check if the user has folder-only permission. + // Check if the user has folder-only permission (server-authoritative). await checkUserFolderPermission(); // Determine effective root folder. const username = localStorage.getItem("username") || "root"; let effectiveRoot = "root"; let effectiveLabel = "(Root)"; - if (window.userFolderOnly) { - effectiveRoot = username; // Use the username as the personal root. + if (window.userFolderOnly && username) { + effectiveRoot = username; // personal root effectiveLabel = `(Root)`; - // Force override of any saved folder. localStorage.setItem("lastOpenedFolder", username); window.currentFolder = username; } else { window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root"; } - // Build fetch URL. - let fetchUrl = '/api/folder/getFolderList.php'; - if (window.userFolderOnly) { - fetchUrl += '?restricted=1'; - } - console.log("Fetching folder list from:", fetchUrl); + // Fetch folder list from the server (server enforces scope). + const res = await fetchWithCsrf('/api/folder/getFolderList.php', { + method: 'GET', + credentials: 'include' + }); - // Fetch folder list from the server. - const response = await fetch(fetchUrl); - if (response.status === 401) { - console.error("Unauthorized: Please log in to view folders."); + if (res.status === 401) { showToast("Session expired. Please log in again."); window.location.href = "/api/auth/logout.php"; return; } - let folderData = await response.json(); - console.log("Folder data received:", folderData); + if (res.status === 403) { + showToast("You don't have permission to view folders."); + return; + } + + const folderData = await safeJson(res); + let folders = []; if (Array.isArray(folderData) && folderData.length && typeof folderData[0] === "object" && folderData[0].folder) { folders = folderData.map(item => item.folder); @@ -413,13 +427,12 @@ export async function loadFolderTree(selectedFolder) { folders = folderData; } - // Remove any global "root" entry. + // Remove any global "root" entry (server shouldn't return it, but be safe). folders = folders.filter(folder => folder.toLowerCase() !== "root"); - // If restricted, filter folders: keep only those that start with effectiveRoot + "/" (do not include effectiveRoot itself). + // If restricted, filter client-side view to subtree for UX (server still enforces). if (window.userFolderOnly && effectiveRoot !== "root") { folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/")); - // Force current folder to be the effective root. localStorage.setItem("lastOpenedFolder", effectiveRoot); window.currentFolder = effectiveRoot; } @@ -455,8 +468,9 @@ export async function loadFolderTree(selectedFolder) { } localStorage.setItem("lastOpenedFolder", window.currentFolder); - // Initial breadcrumb update + // Initial breadcrumb + file list updateBreadcrumbTitle(window.currentFolder); + applyFolderCapabilities(window.currentFolder); loadFileList(window.currentFolder); const folderState = loadFolderTreeState(); @@ -480,8 +494,8 @@ export async function loadFolderTree(selectedFolder) { window.currentFolder = selected; localStorage.setItem("lastOpenedFolder", selected); - // Safe breadcrumb update updateBreadcrumbTitle(selected); + applyFolderCapabilities(selected); loadFileList(selected); }); }); @@ -493,7 +507,7 @@ export async function loadFolderTree(selectedFolder) { e.stopPropagation(); const nestedUl = container.querySelector("#rootRow + ul"); if (nestedUl) { - let state = loadFolderTreeState(); + const state = loadFolderTreeState(); if (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded")) { nestedUl.classList.remove("collapsed"); nestedUl.classList.add("expanded"); @@ -516,7 +530,7 @@ export async function loadFolderTree(selectedFolder) { e.stopPropagation(); const siblingUl = this.parentNode.querySelector("ul"); const folderPath = this.getAttribute("data-folder"); - let state = loadFolderTreeState(); + const state = loadFolderTreeState(); if (siblingUl) { if (siblingUl.classList.contains("collapsed") || !siblingUl.classList.contains("expanded")) { siblingUl.classList.remove("collapsed"); @@ -536,10 +550,12 @@ export async function loadFolderTree(selectedFolder) { } catch (error) { console.error("Error loading folder tree:", error); + if (error.status === 403) { + showToast("You don't have permission to view folders."); + } } } - // For backward compatibility. export function loadFolderList(selectedFolder) { loadFolderTree(selectedFolder); @@ -548,8 +564,11 @@ export function loadFolderList(selectedFolder) { /* ---------------------- Folder Management (Rename, Delete, Create) ----------------------*/ -document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal); -document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal); +const renameBtn = document.getElementById("renameFolderBtn"); +if (renameBtn) renameBtn.addEventListener("click", openRenameFolderModal); + +const deleteBtn = document.getElementById("deleteFolderBtn"); +if (deleteBtn) deleteBtn.addEventListener("click", openDeleteFolderModal); export function openRenameFolderModal() { const selectedFolder = window.currentFolder || "root"; @@ -558,61 +577,69 @@ export function openRenameFolderModal() { return; } const parts = selectedFolder.split("/"); - document.getElementById("newRenameFolderName").value = parts[parts.length - 1]; - document.getElementById("renameFolderModal").style.display = "block"; + const input = document.getElementById("newRenameFolderName"); + const modal = document.getElementById("renameFolderModal"); + if (!input || !modal) return; + input.value = parts[parts.length - 1]; + modal.style.display = "block"; setTimeout(() => { - const input = document.getElementById("newRenameFolderName"); input.focus(); input.select(); }, 100); } -document.getElementById("cancelRenameFolder").addEventListener("click", function () { - document.getElementById("renameFolderModal").style.display = "none"; - document.getElementById("newRenameFolderName").value = ""; -}); +const cancelRename = document.getElementById("cancelRenameFolder"); +if (cancelRename) { + cancelRename.addEventListener("click", function () { + const modal = document.getElementById("renameFolderModal"); + const input = document.getElementById("newRenameFolderName"); + if (modal) modal.style.display = "none"; + if (input) input.value = ""; + }); +} attachEnterKeyListener("renameFolderModal", "submitRenameFolder"); -document.getElementById("submitRenameFolder").addEventListener("click", function (event) { - event.preventDefault(); - const selectedFolder = window.currentFolder || "root"; - const newNameBasename = document.getElementById("newRenameFolderName").value.trim(); - if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) { - showToast("Please enter a valid new folder name."); - return; - } - const parentPath = getParentFolder(selectedFolder); - const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename; - const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); - if (!csrfToken) { - showToast("CSRF token not loaded yet! Please try again."); - return; - } - fetch("/api/folder/renameFolder.php", { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrfToken - }, - body: JSON.stringify({ oldFolder: window.currentFolder, newFolder: newFolderFull }) - }) - .then(response => response.json()) - .then(data => { - if (data.success) { - showToast("Folder renamed successfully!"); - window.currentFolder = newFolderFull; - localStorage.setItem("lastOpenedFolder", newFolderFull); - loadFolderList(newFolderFull); - } else { - showToast("Error: " + (data.error || "Could not rename folder")); - } + +const submitRename = document.getElementById("submitRenameFolder"); +if (submitRename) { + submitRename.addEventListener("click", function (event) { + event.preventDefault(); + const selectedFolder = window.currentFolder || "root"; + const input = document.getElementById("newRenameFolderName"); + if (!input) return; + const newNameBasename = input.value.trim(); + if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) { + showToast("Please enter a valid new folder name."); + return; + } + const parentPath = getParentFolder(selectedFolder); + const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename; + + fetchWithCsrf("/api/folder/renameFolder.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ oldFolder: window.currentFolder, newFolder: newFolderFull }) }) - .catch(error => console.error("Error renaming folder:", error)) - .finally(() => { - document.getElementById("renameFolderModal").style.display = "none"; - document.getElementById("newRenameFolderName").value = ""; - }); -}); + .then(safeJson) + .then(data => { + if (data.success) { + showToast("Folder renamed successfully!"); + window.currentFolder = newFolderFull; + localStorage.setItem("lastOpenedFolder", newFolderFull); + loadFolderList(newFolderFull); + } else { + showToast("Error: " + (data.error || "Could not rename folder")); + } + }) + .catch(error => console.error("Error renaming folder:", error)) + .finally(() => { + const modal = document.getElementById("renameFolderModal"); + const input2 = document.getElementById("newRenameFolderName"); + if (modal) modal.style.display = "none"; + if (input2) input2.value = ""; + }); + }); +} export function openDeleteFolderModal() { const selectedFolder = window.currentFolder || "root"; @@ -620,102 +647,117 @@ export function openDeleteFolderModal() { showToast("Please select a valid folder to delete."); return; } - document.getElementById("deleteFolderMessage").textContent = - "Are you sure you want to delete folder " + selectedFolder + "?"; - document.getElementById("deleteFolderModal").style.display = "block"; + const msgEl = document.getElementById("deleteFolderMessage"); + const modal = document.getElementById("deleteFolderModal"); + if (!msgEl || !modal) return; + msgEl.textContent = "Are you sure you want to delete folder " + selectedFolder + "?"; + modal.style.display = "block"; } -document.getElementById("cancelDeleteFolder").addEventListener("click", function () { - document.getElementById("deleteFolderModal").style.display = "none"; -}); +const cancelDelete = document.getElementById("cancelDeleteFolder"); +if (cancelDelete) { + cancelDelete.addEventListener("click", function () { + const modal = document.getElementById("deleteFolderModal"); + if (modal) modal.style.display = "none"; + }); +} attachEnterKeyListener("deleteFolderModal", "confirmDeleteFolder"); -document.getElementById("confirmDeleteFolder").addEventListener("click", function () { - const selectedFolder = window.currentFolder || "root"; - const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); - fetch("/api/folder/deleteFolder.php", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrfToken - }, - body: JSON.stringify({ folder: selectedFolder }) - }) - .then(response => response.json()) - .then(data => { - if (data.success) { - showToast("Folder deleted successfully!"); - window.currentFolder = getParentFolder(selectedFolder); - localStorage.setItem("lastOpenedFolder", window.currentFolder); - loadFolderList(window.currentFolder); - } else { - showToast("Error: " + (data.error || "Could not delete folder")); - } + +const confirmDelete = document.getElementById("confirmDeleteFolder"); +if (confirmDelete) { + confirmDelete.addEventListener("click", function () { + const selectedFolder = window.currentFolder || "root"; + + fetchWithCsrf("/api/folder/deleteFolder.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ folder: selectedFolder }) }) - .catch(error => console.error("Error deleting folder:", error)) - .finally(() => { - document.getElementById("deleteFolderModal").style.display = "none"; - }); -}); - -document.getElementById("createFolderBtn").addEventListener("click", function () { - document.getElementById("createFolderModal").style.display = "block"; - document.getElementById("newFolderName").focus(); -}); - -document.getElementById("cancelCreateFolder").addEventListener("click", function () { - document.getElementById("createFolderModal").style.display = "none"; - document.getElementById("newFolderName").value = ""; -}); -attachEnterKeyListener("createFolderModal", "submitCreateFolder"); -document.getElementById("submitCreateFolder").addEventListener("click", async () => { - const folderInput = document.getElementById("newFolderName").value.trim(); - if (!folderInput) return showToast("Please enter a folder name."); - - const selectedFolder = window.currentFolder || "root"; - const parent = selectedFolder === "root" ? "" : selectedFolder; - - // 1) Guarantee fresh CSRF - try { - await loadCsrfToken(); - } catch { - return showToast("Could not refresh CSRF token. Please reload."); - } - - // 2) Call with fetchWithCsrf - fetchWithCsrf("/api/folder/createFolder.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ folderName: folderInput, parent }) - }) - .then(async res => { - if (!res.ok) { - // pull out a JSON error, or fallback to status text - let err; - try { - const j = await res.json(); - err = j.error || j.message || res.statusText; - } catch { - err = res.statusText; + .then(safeJson) + .then(data => { + if (data.success) { + showToast("Folder deleted successfully!"); + window.currentFolder = getParentFolder(selectedFolder); + localStorage.setItem("lastOpenedFolder", window.currentFolder); + loadFolderList(window.currentFolder); + } else { + showToast("Error: " + (data.error || "Could not delete folder")); } - throw new Error(err); - } - return res.json(); + }) + .catch(error => console.error("Error deleting folder:", error)) + .finally(() => { + const modal = document.getElementById("deleteFolderModal"); + if (modal) modal.style.display = "none"; + }); + }); +} + +const createBtn = document.getElementById("createFolderBtn"); +if (createBtn) { + createBtn.addEventListener("click", function () { + const modal = document.getElementById("createFolderModal"); + const input = document.getElementById("newFolderName"); + if (modal) modal.style.display = "block"; + if (input) input.focus(); + }); +} + +const cancelCreate = document.getElementById("cancelCreateFolder"); +if (cancelCreate) { + cancelCreate.addEventListener("click", function () { + const modal = document.getElementById("createFolderModal"); + const input = document.getElementById("newFolderName"); + if (modal) modal.style.display = "none"; + if (input) input.value = ""; + }); +} +attachEnterKeyListener("createFolderModal", "submitCreateFolder"); + +const submitCreate = document.getElementById("submitCreateFolder"); +if (submitCreate) { + submitCreate.addEventListener("click", async () => { + const input = document.getElementById("newFolderName"); + const folderInput = input ? input.value.trim() : ""; + if (!folderInput) return showToast("Please enter a folder name."); + + const selectedFolder = window.currentFolder || "root"; + const parent = selectedFolder === "root" ? "" : selectedFolder; + + // 1) Guarantee fresh CSRF + try { + await loadCsrfToken(); + } catch { + return showToast("Could not refresh CSRF token. Please reload."); + } + + // 2) Call with fetchWithCsrf + fetchWithCsrf("/api/folder/createFolder.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ folderName: folderInput, parent }) }) - .then(data => { - showToast("Folder created!"); - const full = parent ? `${parent}/${folderInput}` : folderInput; - window.currentFolder = full; - localStorage.setItem("lastOpenedFolder", full); - loadFolderList(full); - }) - .catch(e => { - showToast("Error creating folder: " + e.message); - }) - .finally(() => { - document.getElementById("createFolderModal").style.display = "none"; - document.getElementById("newFolderName").value = ""; - }); -}); + .then(safeJson) + .then(data => { + if (!data.success) throw new Error(data.error || "Server rejected the request"); + showToast("Folder created!"); + const full = parent ? `${parent}/${folderInput}` : folderInput; + window.currentFolder = full; + localStorage.setItem("lastOpenedFolder", full); + loadFolderList(full); + }) + .catch(e => { + showToast("Error creating folder: " + e.message); + }) + .finally(() => { + const modal = document.getElementById("createFolderModal"); + const input2 = document.getElementById("newFolderName"); + if (modal) modal.style.display = "none"; + if (input2) input2.value = ""; + }); + }); +} // ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ---------- export function showFolderManagerContextMenu(x, y, menuItems) { @@ -773,21 +815,28 @@ export function hideFolderManagerContextMenu() { } function folderManagerContextMenuHandler(e) { - e.preventDefault(); - e.stopPropagation(); const target = e.target.closest(".folder-option, .breadcrumb-link"); if (!target) return; + + e.preventDefault(); + e.stopPropagation(); + const folder = target.getAttribute("data-folder"); if (!folder) return; window.currentFolder = folder; + + // Visual selection document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected")); target.classList.add("selected"); + const menuItems = [ { label: t("create_folder"), action: () => { - document.getElementById("createFolderModal").style.display = "block"; - document.getElementById("newFolderName").focus(); + const modal = document.getElementById("createFolderModal"); + const input = document.getElementById("newFolderName"); + if (modal) modal.style.display = "block"; + if (input) input.focus(); } }, { @@ -806,17 +855,34 @@ function folderManagerContextMenuHandler(e) { showFolderManagerContextMenu(e.pageX, e.pageY, menuItems); } +// Delegate contextmenu so it works with dynamically re-rendered breadcrumbs function bindFolderManagerContextMenu() { - const container = document.getElementById("folderTreeContainer"); - if (container) { - container.removeEventListener("contextmenu", folderManagerContextMenuHandler); - container.addEventListener("contextmenu", folderManagerContextMenuHandler, false); + const tree = document.getElementById("folderTreeContainer"); + if (tree) { + // remove old bound handler if present + if (tree._ctxHandler) { + tree.removeEventListener("contextmenu", tree._ctxHandler, false); + } + tree._ctxHandler = function (e) { + const onOption = e.target.closest(".folder-option"); + if (!onOption) return; + folderManagerContextMenuHandler(e); + }; + tree.addEventListener("contextmenu", tree._ctxHandler, false); + } + + const title = document.getElementById("fileListTitle"); + if (title) { + if (title._ctxHandler) { + title.removeEventListener("contextmenu", title._ctxHandler, false); + } + title._ctxHandler = function (e) { + const onCrumb = e.target.closest(".breadcrumb-link"); + if (!onCrumb) return; + folderManagerContextMenuHandler(e); + }; + title.addEventListener("contextmenu", title._ctxHandler, false); } - const breadcrumbNodes = document.querySelectorAll(".breadcrumb-link"); - breadcrumbNodes.forEach(node => { - node.removeEventListener("contextmenu", folderManagerContextMenuHandler); - node.addEventListener("contextmenu", folderManagerContextMenuHandler, false); - }); } document.addEventListener("click", function () { @@ -825,8 +891,8 @@ document.addEventListener("click", function () { document.addEventListener("DOMContentLoaded", function () { document.addEventListener("keydown", function (e) { - const tag = e.target.tagName.toLowerCase(); - if (tag === "input" || tag === "textarea" || e.target.isContentEditable) { + const tag = e.target.tagName ? e.target.tagName.toLowerCase() : ""; + if (tag === "input" || tag === "textarea" || (e.target && e.target.isContentEditable)) { return; } if (e.key === "Delete" || e.key === "Backspace" || e.keyCode === 46 || e.keyCode === 8) { @@ -847,7 +913,6 @@ document.addEventListener("DOMContentLoaded", function () { showToast("Please select a valid folder to share."); return; } - // Call the folder share modal from the module. openFolderShareModal(selectedFolder); }); } else { @@ -855,4 +920,5 @@ document.addEventListener("DOMContentLoaded", function () { } }); +// Initial context menu delegation bind bindFolderManagerContextMenu(); \ No newline at end of file diff --git a/public/js/main.js b/public/js/main.js index e18ef78..0f1024b 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -51,6 +51,52 @@ async function fetchWithCsrfAndRefresh(input, init = {}) { // Replace global fetch with the wrapped version so *all* callers benefit. window.fetch = fetchWithCsrfAndRefresh; +/* ========================= + SAFE API HELPERS + ========================= */ +export async function apiGETJSON(url, opts = {}) { + const res = await fetch(url, { credentials: "include", ...opts }); + if (res.status === 401) throw new Error("auth"); + if (res.status === 403) throw new Error("forbidden"); + if (!res.ok) throw new Error(`http ${res.status}`); + try { return await res.json(); } catch { return {}; } +} + +export async function apiPOSTJSON(url, body, opts = {}) { + const headers = { + "Content-Type": "application/json", + "X-CSRF-Token": getCsrfToken(), + ...(opts.headers || {}) + }; + const res = await fetch(url, { + method: "POST", + credentials: "include", + headers, + body: JSON.stringify(body ?? {}), + ...opts + }); + if (res.status === 401) throw new Error("auth"); + if (res.status === 403) throw new Error("forbidden"); + if (!res.ok) throw new Error(`http ${res.status}`); + try { return await res.json(); } catch { return {}; } +} + +// Optional: expose on window for legacy callers +window.apiGETJSON = apiGETJSON; +window.apiPOSTJSON = apiPOSTJSON; + +// Global handler to keep UX friendly if something forgets to catch +window.addEventListener("unhandledrejection", (ev) => { + const msg = (ev?.reason && ev.reason.message) || ""; + if (msg === "auth") { + showToast(t("please_sign_in_again") || "Please sign in again.", "error"); + ev.preventDefault(); + } else if (msg === "forbidden") { + showToast(t("no_access_to_resource") || "You don’t have access to that.", "error"); + ev.preventDefault(); + } +}); + /* ========================= APP INIT ========================= */ @@ -94,7 +140,7 @@ export function initializeApp() { initUpload(); loadFolderTree(); setupTrashRestoreDelete(); - loadAdminConfigFunc(); + // NOTE: loadAdminConfigFunc() is called once in DOMContentLoaded; calling here would duplicate requests. const helpBtn = document.getElementById("folderHelpBtn"); const helpTooltip = document.getElementById("folderHelpTooltip"); @@ -170,6 +216,7 @@ window.openDownloadModal = openDownloadModal; window.currentFolder = "root"; document.addEventListener("DOMContentLoaded", function () { + // Load admin config once here; non-admins may get 403, which is fine. loadAdminConfigFunc(); // i18n diff --git a/public/webdav.php b/public/webdav.php index 1433262..1ff6127 100644 --- a/public/webdav.php +++ b/public/webdav.php @@ -13,56 +13,62 @@ if ( } // ─── 1) Bootstrap & load models ───────────────────────────────────────────── -require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT +require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, loadUserPermissions(), etc. require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV -require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole(), loadFolderPermission() -require_once __DIR__ . '/../src/models/AdminModel.php'; // AdminModel::getConfig() +require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole() +require_once __DIR__ . '/../src/models/AdminModel.php';// AdminModel::getConfig() +require_once __DIR__ . '/../src/lib/ACL.php'; // ACL checks +require_once __DIR__ . '/../src/webdav/CurrentUser.php'; // ─── 1.1) Global WebDAV feature toggle ────────────────────────────────────── -$adminConfig = AdminModel::getConfig(); -$enableWebDAV = isset($adminConfig['enableWebDAV']) && $adminConfig['enableWebDAV']; +$adminConfig = AdminModel::getConfig(); +$enableWebDAV = isset($adminConfig['enableWebDAV']) && $adminConfig['enableWebDAV']; if (!$enableWebDAV) { header('HTTP/1.1 403 Forbidden'); echo 'WebDAV access is currently disabled by administrator.'; exit; } -// ─── 2) Load WebDAV directory implementation ────────────────────────── +// ─── 2) Load WebDAV directory implementation (ACL-aware) ──────────────────── require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php'; + use Sabre\DAV\Server; use Sabre\DAV\Auth\Backend\BasicCallBack; use Sabre\DAV\Auth\Plugin as AuthPlugin; use Sabre\DAV\Locks\Plugin as LocksPlugin; use Sabre\DAV\Locks\Backend\File as LocksFileBackend; use FileRise\WebDAV\FileRiseDirectory; +use FileRise\WebDAV\CurrentUser; -// ─── 3) HTTP‑Basic backend ───────────────────────────────────────────────── +// ─── 3) HTTP-Basic backend (delegates to your AuthModel) ──────────────────── $authBackend = new BasicCallBack(function(string $user, string $pass) { return \AuthModel::authenticate($user, $pass) !== false; }); $authPlugin = new AuthPlugin($authBackend, 'FileRise'); -// ─── 4) Determine user scope ──────────────────────────────────────────────── -$user = $_SERVER['PHP_AUTH_USER'] ?? ''; -$isAdmin = (\AuthModel::getUserRole($user) === '1'); -$folderOnly = (bool)\AuthModel::loadFolderPermission($user); - -if ($isAdmin || !$folderOnly) { - // Admins (or users without folder-only restriction) see the full /uploads - $rootPath = rtrim(UPLOAD_DIR, '/\\'); -} else { - // Folder‑only users see only /uploads/{username} - $rootPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $user; - if (!is_dir($rootPath)) { - mkdir($rootPath, 0755, true); - } +// ─── 4) Resolve authenticated user + perms ────────────────────────────────── +$user = $_SERVER['PHP_AUTH_USER'] ?? ''; +if ($user === '') { + header('HTTP/1.1 401 Unauthorized'); + header('WWW-Authenticate: Basic realm="FileRise"'); + echo 'Authentication required.'; + exit; } -// ─── 5) Spin up SabreDAV ──────────────────────────────────────────────────── +$perms = is_callable('loadUserPermissions') ? (loadUserPermissions($user) ?: []) : []; +$isAdmin = (\AuthModel::getUserRole($user) === '1'); + +// set for metadata attribution in WebDAV writes +CurrentUser::set($user); + +// ─── 5) Mount the real uploads root; ACL filters everything at node level ─── +$rootPath = rtrim(UPLOAD_DIR, '/\\'); + $server = new Server([ - new FileRiseDirectory($rootPath, $user, $folderOnly), + new FileRiseDirectory($rootPath, $user, $isAdmin, $perms), ]); +// Auth + Locks $server->addPlugin($authPlugin); $server->addPlugin( new LocksPlugin( @@ -70,5 +76,8 @@ $server->addPlugin( ) ); +// Base URI (adjust if you serve from a subdir or rewrite rule) $server->setBaseUri('/webdav.php/'); + +// Execute $server->exec(); \ No newline at end of file diff --git a/src/controllers/FileController.php b/src/controllers/FileController.php index ef38d00..ec307c5 100644 --- a/src/controllers/FileController.php +++ b/src/controllers/FileController.php @@ -4,7 +4,8 @@ require_once __DIR__ . '/../../config/config.php'; require_once PROJECT_ROOT . '/src/models/FileModel.php'; require_once PROJECT_ROOT . '/src/models/UserModel.php'; - +require_once PROJECT_ROOT . '/src/models/FolderModel.php'; +require_once PROJECT_ROOT . '/src/lib/ACL.php'; class FileController { @@ -12,17 +13,11 @@ 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); @@ -52,9 +47,7 @@ class FileController return []; } - // Always return an array for user permissions. - private function loadPerms(string $username): array - { + private function loadPerms(string $username): array { try { if (function_exists('loadUserPermissions')) { $p = loadUserPermissions($username); @@ -72,14 +65,10 @@ class FileController return []; } - /** Enforce that (a) folder-only users act only in their subtree, and - * (b) non-admins own all files in the provided list (metadata.uploader === $username). - * Returns an error string on violation, or null if ok. */ private function enforceScopeAndOwnership(string $folder, array $files, string $username, array $userPermissions): ?string { $ignoreOwnership = $this->isAdmin($userPermissions) || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); - - // Folder-only users must stay in "" subtree + if ($this->isFolderOnly($userPermissions) && !$this->isAdmin($userPermissions)) { $folder = trim($folder); if ($folder !== '' && strtolower($folder) !== 'root') { @@ -88,9 +77,9 @@ class FileController } } } - + if ($ignoreOwnership) return null; - + $metadata = $this->loadFolderMetadata($folder); foreach ($files as $f) { $name = basename((string)$f); @@ -100,1005 +89,468 @@ class FileController } 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."; - } + + $f = trim($folder); + while ($f !== '' && strtolower($f) !== 'root') { + if (FolderModel::getOwnerFor($f) === $username) return null; + $pos = strrpos($f, '/'); + $f = $pos === false ? '' : substr($f, 0, $pos); } - return null; + return "Forbidden: folder scope violation."; } - // --- 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; + // --- small helpers --- + private function _jsonStart(): void { + if (session_status() !== PHP_SESSION_ACTIVE) session_start(); + 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); + }); } - return true; -} - -private function _requireAuth(): bool { - if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - $this->_jsonOut(['error' => 'Unauthorized'], 401); - return false; + 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 : []; + } + private function _normalizeFolder($f): string { + $f = trim((string)$f); + if ($f === '' || strtolower($f) === 'root') return 'root'; + return $f; + } + private function _validFolder($f): bool { + if ($f === 'root') return true; + return (bool)preg_match(REGEX_FOLDER_NAME, $f); + } + private function _validFile($f): bool { + $f = basename((string)$f); + return $f !== '' && (bool)preg_match(REGEX_FILE_NAME, $f); } - return true; -} -private function _readJsonBody(): array { - $raw = file_get_contents('php://input'); - $data = json_decode($raw, true); - return is_array($data) ? $data : []; -} + /* ========================= + * Actions + * ========================= */ - /** - * @OA\Post( - * path="/api/file/copyFiles.php", - * summary="Copy files between folders", - * description="Copies files from a source folder to a destination folder. It validates folder names, handles file renaming if a conflict exists, and updates metadata accordingly.", - * operationId="copyFiles", - * tags={"Files"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"source", "destination", "files"}, - * @OA\Property(property="source", type="string", example="root"), - * @OA\Property(property="destination", type="string", example="Documents"), - * @OA\Property( - * property="files", - * type="array", - * @OA\Items(type="string", example="example.pdf") - * ) - * ) - * ), - * @OA\Response( - * response=200, - * description="Files copied successfully", - * @OA\JsonContent( - * @OA\Property(property="success", type="string", example="Files copied successfully") - * ) - * ), - * @OA\Response( - * response=400, - * description="Invalid request or input" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token or read-only permission" - * ) - * ) - * - * Handles copying files from a source folder to a destination folder. - * - * @return void Outputs JSON response. - */ public function copyFiles() { - header('Content-Type: application/json'); + $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; - } + $data = $this->_readJsonBody(); + if (!$data || !isset($data['source'], $data['destination'], $data['files']) || !is_array($data['files'])) { + $this->_jsonOut(["error" => "Invalid request"], 400); return; + } - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } + $sourceFolder = $this->_normalizeFolder($data['source']); + $destinationFolder = $this->_normalizeFolder($data['destination']); + $files = $data['files']; - // Check user permissions (assuming loadUserPermissions() is available). - $username = $_SESSION['username'] ?? ''; - $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; - } + if (!$this->_validFolder($sourceFolder) || !$this->_validFolder($destinationFolder)) { + $this->_jsonOut(["error" => "Invalid folder name(s)."], 400); return; + } - // Get JSON input data. - $data = json_decode(file_get_contents("php://input"), true); - if ( - !$data || - !isset($data['source']) || - !isset($data['destination']) || - !isset($data['files']) - ) { - http_response_code(400); - echo json_encode(["error" => "Invalid request"]); - exit; - } + $username = $_SESSION['username'] ?? ''; + $userPermissions = $this->loadPerms($username); - $sourceFolder = trim($data['source']); - $destinationFolder = trim($data['destination']); - $files = $data['files']; + // 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)) { + $this->_jsonOut(["error"=>"Forbidden: no read access to source"], 403); return; + } + if (!ACL::canWrite($username, $userPermissions, $destinationFolder)) { + $this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return; + } - // Validate folder names. - if ($sourceFolder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $sourceFolder)) { - echo json_encode(["error" => "Invalid source folder name."]); - exit; - } - if ($destinationFolder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $destinationFolder)) { - echo json_encode(["error" => "Invalid destination folder name."]); - exit; - } + // scope/ownership + $violation = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions); + if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } + $dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions); + if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } - // 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); + $result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files); + $this->_jsonOut($result); + } catch (Throwable $e) { + error_log('FileController::copyFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); + $this->_jsonOut(['error' => 'Internal server error while copying files.'], 500); + } finally { $this->_jsonEnd(); } } - /** - * @OA\Post( - * path="/api/file/deleteFiles.php", - * summary="Delete files (move to trash)", - * description="Moves the specified files from the given folder to the trash and updates metadata accordingly.", - * operationId="deleteFiles", - * tags={"Files"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"files"}, - * @OA\Property(property="folder", type="string", example="Documents"), - * @OA\Property( - * property="files", - * type="array", - * @OA\Items(type="string", example="example.pdf") - * ) - * ) - * ), - * @OA\Response( - * response=200, - * description="Files moved to Trash successfully", - * @OA\JsonContent( - * @OA\Property(property="success", type="string", example="Files moved to Trash: file1.pdf, file2.doc") - * ) - * ), - * @OA\Response( - * response=400, - * description="Invalid request" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token or permission denied" - * ) - * ) - * - * Handles deletion of files (moves them to Trash) by updating metadata. - * - * @return void Outputs JSON response. - */ public function deleteFiles() { - header('Content-Type: application/json'); + $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; - } + $data = $this->_readJsonBody(); + if (!isset($data['files']) || !is_array($data['files'])) { + $this->_jsonOut(["error" => "No file names provided"], 400); return; + } - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } + $folder = $this->_normalizeFolder($data['folder'] ?? 'root'); + if (!$this->_validFolder($folder)) { + $this->_jsonOut(["error" => "Invalid folder name."], 400); return; + } - // Load user's permissions. - $username = $_SESSION['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; - } + $username = $_SESSION['username'] ?? ''; + $userPermissions = $this->loadPerms($username); - // Get JSON input. - $data = json_decode(file_get_contents("php://input"), true); - if (!isset($data['files']) || !is_array($data['files'])) { - http_response_code(400); - echo json_encode(["error" => "No file names provided"]); - exit; - } + if (!ACL::canWrite($username, $userPermissions, $folder)) { + $this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return; + } - // Determine folder; default to 'root'. - $folder = isset($data['folder']) ? trim($data['folder']) : 'root'; - if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { - echo json_encode(["error" => "Invalid folder name."]); - exit; - } - $folder = trim($folder, "/\\ "); + $violation = $this->enforceScopeAndOwnership($folder, $data['files'], $username, $userPermissions); + if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } - // 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); + $result = FileModel::deleteFiles($folder, $data['files']); + $this->_jsonOut($result); + } catch (Throwable $e) { + error_log('FileController::deleteFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); + $this->_jsonOut(['error' => 'Internal server error while deleting files.'], 500); + } finally { $this->_jsonEnd(); } } - /** - * @OA\Post( - * path="/api/file/moveFiles.php", - * summary="Move files between folders", - * description="Moves files from a source folder to a destination folder, updating metadata accordingly.", - * operationId="moveFiles", - * tags={"Files"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"source", "destination", "files"}, - * @OA\Property(property="source", type="string", example="root"), - * @OA\Property(property="destination", type="string", example="Archives"), - * @OA\Property( - * property="files", - * type="array", - * @OA\Items(type="string", example="report.pdf") - * ) - * ) - * ), - * @OA\Response( - * response=200, - * description="Files moved successfully", - * @OA\JsonContent( - * @OA\Property(property="success", type="string", example="Files moved successfully") - * ) - * ), - * @OA\Response( - * response=400, - * description="Invalid request or input" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token or permission denied" - * ) - * ) - * - * Handles moving files from a source folder to a destination folder. - * - * @return void Outputs JSON response. - */ public function moveFiles() { - header('Content-Type: application/json'); + $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; - } + $data = $this->_readJsonBody(); + if (!$data || !isset($data['source'], $data['destination'], $data['files']) || !is_array($data['files'])) { + $this->_jsonOut(["error" => "Invalid request"], 400); return; + } - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } + $sourceFolder = $this->_normalizeFolder($data['source']); + $destinationFolder = $this->_normalizeFolder($data['destination']); + if (!$this->_validFolder($sourceFolder) || !$this->_validFolder($destinationFolder)) { + $this->_jsonOut(["error" => "Invalid folder name(s)."], 400); return; + } - // Verify that the user is not read-only. - $username = $_SESSION['username'] ?? ''; - $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; - } + $username = $_SESSION['username'] ?? ''; + $userPermissions = $this->loadPerms($username); - // Get JSON input. - $data = json_decode(file_get_contents("php://input"), true); - if ( - !$data || - !isset($data['source']) || - !isset($data['destination']) || - !isset($data['files']) - ) { - http_response_code(400); - echo json_encode(["error" => "Invalid request"]); - exit; - } + // Require write on both source and destination to be safe + if (!ACL::canWrite($username, $userPermissions, $sourceFolder)) { + $this->_jsonOut(["error"=>"Forbidden: no write access to source"], 403); return; + } + if (!ACL::canWrite($username, $userPermissions, $destinationFolder)) { + $this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return; + } - $sourceFolder = trim($data['source']) ?: 'root'; - $destinationFolder = trim($data['destination']) ?: 'root'; - - // Validate folder names. - if ($sourceFolder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $sourceFolder)) { - echo json_encode(["error" => "Invalid source folder name."]); - exit; - } - if ($destinationFolder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $destinationFolder)) { - echo json_encode(["error" => "Invalid destination folder name."]); - 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); - } - - /** - * @OA\Post( - * path="/api/file/renameFile.php", - * summary="Rename a file", - * description="Renames a file within a specified folder and updates folder metadata. If a file with the new name exists, a unique name is generated.", - * operationId="renameFile", - * tags={"Files"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"folder", "oldName", "newName"}, - * @OA\Property(property="folder", type="string", example="Documents"), - * @OA\Property(property="oldName", type="string", example="oldfile.pdf"), - * @OA\Property(property="newName", type="string", example="newfile.pdf") - * ) - * ), - * @OA\Response( - * response=200, - * description="File renamed successfully", - * @OA\JsonContent( - * @OA\Property(property="success", type="string", example="File renamed successfully"), - * @OA\Property(property="newName", type="string", example="newfile.pdf") - * ) - * ), - * @OA\Response( - * response=400, - * description="Invalid input" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token or permission denied" - * ) - * ) - * - * Handles renaming a file by validating input and updating folder metadata. - * - * @return void Outputs a JSON response. - */ - public function renameFile() -{ - $this->_jsonStart(); - try { - if (!$this->_checkCsrf()) return; - if (!$this->_requireAuth()) return; - - $username = $_SESSION['username'] ?? ''; - $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; - } - - $data = $this->_readJsonBody(); - if (!$data || !isset($data['folder'], $data['oldName'], $data['newName'])) { - $this->_jsonOut(["error" => "Invalid input"], 400); - return; - } - - $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)) { - $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; - } - - // Non-admin must own the original - $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'); - } - 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( - * path="/api/file/saveFile.php", - * summary="Save a file", - * description="Saves file content to disk in a specified folder and updates metadata accordingly.", - * operationId="saveFile", - * tags={"Files"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"fileName", "content"}, - * @OA\Property(property="fileName", type="string", example="document.txt"), - * @OA\Property(property="content", type="string", example="File content here"), - * @OA\Property(property="folder", type="string", example="Documents") - * ) - * ), - * @OA\Response( - * response=200, - * description="File saved successfully", - * @OA\JsonContent( - * @OA\Property(property="success", type="string", example="File saved successfully") - * ) - * ), - * @OA\Response( - * response=400, - * description="Invalid request data" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token or read-only permission" - * ) - * ) - * - * Handles saving a file's content and updating the corresponding metadata. - * - * @return void Outputs a JSON response. - */ - public function saveFile() -{ - $this->_jsonStart(); - try { - if (!$this->_checkCsrf()) return; - if (!$this->_requireAuth()) return; - - $username = $_SESSION['username'] ?? ''; - $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; - } - - $data = $this->_readJsonBody(); - if (empty($data) || !isset($data["fileName"], $data["content"])) { - $this->_jsonOut(["error" => "Invalid request data"], 400); - return; - } - - $fileName = basename(trim((string)$data["fileName"])); - $folder = isset($data["folder"]) ? trim((string)$data["folder"]) : "root"; - - 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)) { - $this->_jsonOut(["error" => "Invalid folder name"], 400); - return; - } - - // Folder-only users may only write within their scope - $dv = $this->enforceFolderScope($folder, $username, $userPermissions); - if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } - - // 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); + $violation = $this->enforceScopeAndOwnership($sourceFolder, $data['files'], $username, $userPermissions); if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } - } + $dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions); + if ($dv) { $this->_jsonOut(["error"=>$dv], 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(); + $result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['files']); + $this->_jsonOut($result); + } catch (Throwable $e) { + error_log('FileController::moveFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); + $this->_jsonOut(['error' => 'Internal server error while moving files.'], 500); + } finally { $this->_jsonEnd(); } + } + + public function renameFile() + { + $this->_jsonStart(); + try { + if (!$this->_checkCsrf()) return; + if (!$this->_requireAuth()) return; + + $data = $this->_readJsonBody(); + if (!$data || !isset($data['folder'], $data['oldName'], $data['newName'])) { + $this->_jsonOut(["error" => "Invalid input"], 400); return; + } + + $folder = $this->_normalizeFolder($data['folder']); + $oldName = basename(trim((string)$data['oldName'])); + $newName = basename(trim((string)$data['newName'])); + if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name"], 400); return; } + if (!$this->_validFile($oldName) || !$this->_validFile($newName)) { + $this->_jsonOut(["error"=>"Invalid file name(s)."], 400); return; + } + + $username = $_SESSION['username'] ?? ''; + $userPermissions = $this->loadPerms($username); + + if (!ACL::canWrite($username, $userPermissions, $folder)) { + $this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return; + } + + $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'); + 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(); } + } + + public function saveFile() + { + $this->_jsonStart(); + try { + if (!$this->_checkCsrf()) return; + if (!$this->_requireAuth()) return; + + $data = $this->_readJsonBody(); + if (empty($data) || !isset($data["fileName"])) { + $this->_jsonOut(["error" => "Invalid request data"], 400); return; + } + + $fileName = basename(trim((string)$data["fileName"])); + $folder = $this->_normalizeFolder($data["folder"] ?? 'root'); + if (!$this->_validFile($fileName)) { $this->_jsonOut(["error"=>"Invalid file name."], 400); return; } + if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; } + + $username = $_SESSION['username'] ?? ''; + $userPermissions = $this->loadPerms($username); + + if (!ACL::canWrite($username, $userPermissions, $folder)) { + $this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return; + } + + $dv = $this->enforceFolderScope($folder, $username, $userPermissions); + if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } + + // If overwriting, enforce ownership for non-admins + $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; } + } + + $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; + } + + $content = (string)($data['content'] ?? ''); + $result = FileModel::saveFile($folder, $fileName, $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( - * path="/api/file/download.php", - * summary="Download a file", - * description="Downloads a file from a specified folder. The file is served inline for images or as an attachment for other types.", - * operationId="downloadFile", - * tags={"Files"}, - * @OA\Parameter( - * name="file", - * in="query", - * description="The name of the file to download", - * required=true, - * @OA\Schema(type="string", example="example.pdf") - * ), - * @OA\Parameter( - * name="folder", - * in="query", - * description="The folder in which the file is located. Defaults to root.", - * required=false, - * @OA\Schema(type="string", example="Documents") - * ), - * @OA\Response( - * response=200, - * description="File downloaded successfully" - * ), - * @OA\Response( - * response=400, - * description="Bad Request" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Access forbidden" - * ), - * @OA\Response( - * response=404, - * description="File not found" - * ), - * @OA\Response( - * response=500, - * description="Server error" - * ) - * ) - * - * Downloads a file by validating parameters and serving its content. - * - * @return void Outputs file content with appropriate headers. - */ public function downloadFile() - { - // Check if the user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - header('Content-Type: application/json'); - echo json_encode(["error" => "Unauthorized"]); - exit; - } +{ + if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(["error" => "Unauthorized"]); + exit; + } - // Get GET parameters. - $file = isset($_GET['file']) ? basename($_GET['file']) : ''; - $folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; + $file = isset($_GET['file']) ? basename($_GET['file']) : ''; + $folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; - // Validate the file name using REGEX_FILE_NAME. - if (!preg_match(REGEX_FILE_NAME, $file)) { - http_response_code(400); - echo json_encode(["error" => "Invalid file name."]); - exit; - } + 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; + } -// Ownership enforcement (allow admin OR bypassOwnership) -$username = $_SESSION['username'] ?? ''; -$userPermissions = $this->loadPerms($username); + $username = $_SESSION['username'] ?? ''; + $perms = $this->loadPerms($username); -$ignoreOwnership = $this->isAdmin($userPermissions) - || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); + $ignoreOwnership = $this->isAdmin($perms) + || ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); -if (!$ignoreOwnership) { - $meta = $this->loadFolderMetadata($folder); - if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) { + // 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: you are not the owner of this file."]); + 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."]); + 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; + } + + $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; } - - // Retrieve download info from the model. - $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; +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; } - // Serve the file. - $realFilePath = $downloadInfo['filePath']; - $mimeType = $downloadInfo['mimeType']; - header("Content-Type: " . $mimeType); + $folder = $this->_normalizeFolder($data['folder']); + $files = $data['files']; + if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; } - // For images, serve inline; for others, force download. - $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); - $inlineImageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico']; - if (in_array($ext, $inlineImageTypes)) { - header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"'); - } else { - header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"'); - } - header('Content-Length: ' . filesize($realFilePath)); - readfile($realFilePath); - exit; - } + $username = $_SESSION['username'] ?? ''; + $perms = $this->loadPerms($username); - /** - * @OA\Post( - * path="/api/file/downloadZip.php", - * summary="Download a ZIP archive of selected files", - * description="Creates a ZIP archive of the specified files in a folder and serves it for download.", - * operationId="downloadZip", - * tags={"Files"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"folder", "files"}, - * @OA\Property(property="folder", type="string", example="Documents"), - * @OA\Property( - * property="files", - * type="array", - * @OA\Items(type="string", example="example.pdf") - * ) - * ) - * ), - * @OA\Response( - * response=200, - * description="ZIP archive created and served", - * @OA\MediaType( - * mediaType="application/zip" - * ) - * ), - * @OA\Response( - * response=400, - * description="Bad request or invalid input" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token" - * ), - * @OA\Response( - * response=500, - * description="Server error" - * ) - * ) - * - * Downloads a ZIP archive of the specified files. - * - * @return void Outputs the ZIP file for download. - */ - public function downloadZip() - { - // --- 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); - header('Content-Type: application/json'); - echo json_encode(["error" => "Invalid CSRF token"]); - exit; + // 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; } - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - header('Content-Type: application/json'); - echo json_encode(["error" => "Unauthorized"]); - exit; + $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'); + + if (!$fullView && !$ownOnly) { + $this->_jsonOut(["error" => "Forbidden: no view access to this folder."], 403); return; } - 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'])) { - http_response_code(400); - header('Content-Type: application/json'); - echo json_encode(["error" => "Invalid input."]); - exit; - } - - $folder = $data['folder']; - $files = $data['files']; - - // Validate folder: if not "root", split and validate each segment. - if ($folder !== "root") { - $parts = explode('/', $folder); - foreach ($parts as $part) { - if (empty($part) || $part === '.' || $part === '..' || !preg_match(REGEX_FOLDER_NAME, $part)) { - http_response_code(400); - header('Content-Type: application/json'); - echo json_encode(["error" => "Invalid folder name."]); - exit; + // 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; } } } -// 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'])) { - http_response_code(400); - header('Content-Type: application/json'); - echo json_encode(["error" => $result['error']]); - exit; + $this->_jsonOut(["error" => $result['error']], 400); return; } - $zipPath = $result['zipPath']; - if (!file_exists($zipPath)) { - http_response_code(500); - header('Content-Type: application/json'); - echo json_encode(["error" => "ZIP archive not found."]); - exit; - } + $zipPath = $result['zipPath'] ?? null; + if (!$zipPath || !file_exists($zipPath)) { $this->_jsonOut(["error"=>"ZIP archive not found."], 500); return; } - // Send headers to force download. + // 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'); - // Output the ZIP file. readfile($zipPath); - unlink($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(); } +} - /** - * @OA\Post( - * path="/api/file/extractZip.php", - * summary="Extract ZIP files", - * description="Extracts ZIP archives from a specified folder and updates metadata. Returns a list of extracted files.", - * operationId="extractZip", - * tags={"Files"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"folder", "files"}, - * @OA\Property(property="folder", type="string", example="Documents"), - * @OA\Property( - * property="files", - * type="array", - * @OA\Items(type="string", example="archive.zip") - * ) - * ) - * ), - * @OA\Response( - * response=200, - * description="ZIP files extracted successfully", - * @OA\JsonContent( - * @OA\Property(property="success", type="boolean", example=true), - * @OA\Property(property="extractedFiles", type="array", @OA\Items(type="string")) - * ) - * ), - * @OA\Response( - * response=400, - * description="Invalid input" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token" - * ) - * ) - * - * Handles the extraction of ZIP files from a given folder. - * - * @return void Outputs JSON response. - */ - public function extractZip() - { - header('Content-Type: application/json'); +public function extractZip() +{ + $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; + $data = $this->_readJsonBody(); + if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) { + $this->_jsonOut(["error" => "Invalid input."], 400); return; } - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } + $folder = $this->_normalizeFolder($data['folder']); + if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; } - // 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'])) { - http_response_code(400); - echo json_encode(["error" => "Invalid input."]); - exit; - } - - $folder = $data['folder']; - $files = $data['files']; - - // Validate folder name. - if ($folder !== "root") { - $parts = explode('/', trim($folder)); - foreach ($parts as $part) { - if (empty($part) || $part === '.' || $part === '..' || !preg_match(REGEX_FOLDER_NAME, $part)) { - http_response_code(400); - echo json_encode(["error" => "Invalid folder name."]); - exit; - } - } - } - - // 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; } + $perms = $this->loadPerms($username); - // Delegate to the model. - $result = FileModel::extractZipArchive($folder, $files); - echo json_encode($result); - } + // 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; + } + + $dv = $this->enforceFolderScope($folder, $username, $perms); + 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(); } +} - /** - * @OA\Get( - * path="/api/file/share.php", - * summary="Access a shared file", - * description="Serves a shared file based on a share token. If the file is password protected and no password is provided, a password entry form is returned.", - * operationId="shareFile", - * tags={"Files"}, - * @OA\Parameter( - * name="token", - * in="query", - * description="The share token", - * required=true, - * @OA\Schema(type="string") - * ), - * @OA\Parameter( - * name="pass", - * in="query", - * description="The password for the share if required", - * required=false, - * @OA\Schema(type="string") - * ), - * @OA\Response( - * response=200, - * description="File served or password form rendered", - * @OA\MediaType(mediaType="application/octet-stream") - * ), - * @OA\Response( - * response=400, - * description="Missing token or invalid request" - * ), - * @OA\Response( - * response=403, - * description="Link expired, invalid password, or forbidden access" - * ), - * @OA\Response( - * response=404, - * description="Share link or file not found" - * ) - * ) - * - * Shares a file based on a share token. If the share record is password-protected and no password is provided, - * an HTML form prompting for the password is returned. - * - * @return void Outputs either HTML (password form) or serves the file. - */ public function shareFile() { - // Retrieve and sanitize GET parameters. $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); $providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING); @@ -1109,7 +561,6 @@ if (!$ignoreOwnership) { exit; } - // Get share record from the model. $record = FileModel::getShareRecord($token); if (!$record) { http_response_code(404); @@ -1118,7 +569,6 @@ if (!$ignoreOwnership) { exit; } - // Check expiration. if (time() > $record['expires']) { http_response_code(403); header('Content-Type: application/json'); @@ -1126,73 +576,26 @@ if (!$ignoreOwnership) { exit; } - // If a password is required and not provided, show an HTML form. if (!empty($record['password']) && empty($providedPass)) { header("Content-Type: text/html; charset=utf-8"); -?> - - - - - - - Enter Password - - - - -

This file is protected by a password.

- - - - - - - - - + ?> + + +Enter Password + +

This file is protected by a password.

+
+ + + + +
+ + _jsonStart(); + try { + if (!$this->_requireAuth()) return; - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } + $input = $this->_readJsonBody(); + if (!$input) { $this->_jsonOut(["error" => "Invalid input."], 400); return; } - // Check user permissions. - $username = $_SESSION['username'] ?? ''; - $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; - } + $folder = $this->_normalizeFolder($input['folder'] ?? ''); + $file = basename((string)($input['file'] ?? '')); + $value = isset($input['expirationValue']) ? (int)$input['expirationValue'] : 60; + $unit = $input['expirationUnit'] ?? 'minutes'; + $password = (string)($input['password'] ?? ''); - 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; - } + if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; } + if (!$this->_validFile($file)) { $this->_jsonOut(["error"=>"Invalid file name."], 400); return; } - // Parse POST JSON input. - $input = json_decode(file_get_contents("php://input"), true); - if (!$input) { - http_response_code(400); - echo json_encode(["error" => "Invalid input."]); - exit; - } + $username = $_SESSION['username'] ?? ''; + $userPermissions = $this->loadPerms($username); - // Extract parameters. - $folder = isset($input['folder']) ? trim($input['folder']) : ""; - $file = isset($input['file']) ? basename($input['file']) : ""; - $value = isset($input['expirationValue']) ? intval($input['expirationValue']) : 60; - $unit = isset($input['expirationUnit']) ? $input['expirationUnit'] : 'minutes'; - $password = isset($input['password']) ? $input['password'] : ""; + if (!ACL::canShare($username, $userPermissions, $folder)) { + $this->_jsonOut(["error"=>"Forbidden: no share access"], 403); return; + } - // Validate folder name. - if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { - http_response_code(400); - echo json_encode(["error" => "Invalid folder name."]); - exit; - } + $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) { + $this->_jsonOut(["error" => "Forbidden: you are not the owner of this file."], 403); return; + } + } - // Non-admins can only share their own files -// Ownership enforcement (allow admin OR bypassOwnership) -$username = $_SESSION['username'] ?? ''; -$userPermissions = $this->loadPerms($username); + switch ($unit) { + case 'seconds': $expirationSeconds = $value; break; + case 'hours': $expirationSeconds = $value * 3600; break; + case 'days': $expirationSeconds = $value * 86400; break; + case 'minutes': + default: $expirationSeconds = $value * 60; break; + } -$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; + $result = FileModel::createShareLink($folder, $file, $expirationSeconds, $password); + $this->_jsonOut($result); + } catch (Throwable $e) { + error_log('FileController::createShareLink error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); + $this->_jsonOut(['error' => 'Internal server error while creating share link.'], 500); + } finally { $this->_jsonEnd(); } } + + 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; } + + $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(); } } - // Convert the provided value+unit into seconds - switch ($unit) { - case 'seconds': - $expirationSeconds = $value; - break; - case 'hours': - $expirationSeconds = $value * 3600; - break; - case 'days': - $expirationSeconds = $value * 86400; - break; - case 'minutes': - default: - $expirationSeconds = $value * 60; - break; - } +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; } - // Delegate share link creation to the model. - $result = FileModel::createShareLink($folder, $file, $expirationSeconds, $password); - - echo json_encode($result); - } - - /** - * @OA\Get( - * path="/api/file/getTrashItems.php", - * summary="Get trash items", - * description="Retrieves a list of files that have been moved to Trash, enriched with metadata such as who deleted them and when.", - * operationId="getTrashItems", - * tags={"Files"}, - * @OA\Response( - * response=200, - * description="Trash items retrieved successfully", - * @OA\JsonContent(type="array", @OA\Items(type="object")) - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ) - * ) - * - * Retrieves trash items from the trash metadata file. - * - * @return void Outputs JSON response with trash items. - */ - public function getTrashItems() - { - 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; - } - - // Delegate to the model. - $trashItems = FileModel::getTrashItems(); - echo json_encode($trashItems); - } - - /** - * @OA\Post( - * path="/api/file/restoreFiles.php", - * summary="Restore trashed files", - * description="Restores files from Trash based on provided trash file identifiers and updates metadata.", - * operationId="restoreFiles", - * tags={"Files"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"files"}, - * @OA\Property(property="files", type="array", @OA\Items(type="string", example="trashedFile_1623456789.zip")) - * ) - * ), - * @OA\Response( - * response=200, - * description="Files restored successfully", - * @OA\JsonContent( - * @OA\Property(property="success", type="string", example="Items restored: file1, file2"), - * @OA\Property(property="restored", type="array", @OA\Items(type="string")) - * ) - * ), - * @OA\Response( - * response=400, - * description="Invalid request" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token" - * ) - * ) - * - * Restores files from Trash based on provided trash file names. - * - * @return void Outputs JSON response. - */ - public function restoreFiles() - { - header('Content-Type: application/json'); - - // 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; - } - - // Read POST input. - $data = json_decode(file_get_contents("php://input"), true); + $data = $this->_readJsonBody(); if (!isset($data['files']) || !is_array($data['files'])) { - http_response_code(400); - echo json_encode(["error" => "No file or folder identifiers provided"]); - exit; + $this->_jsonOut(["error" => "No file or folder identifiers provided"], 400); return; } - - // Delegate restoration to the model. $result = FileModel::restoreFiles($data['files']); - echo json_encode($result); - } + $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(); } +} - /** - * @OA\Post( - * path="/api/file/deleteTrashFiles.php", - * summary="Delete trash files", - * description="Deletes trash items based on provided trash file identifiers from the trash metadata and removes the files from disk.", - * operationId="deleteTrashFiles", - * tags={"Files"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * oneOf={ - * @OA\Schema( - * required={"deleteAll"}, - * @OA\Property(property="deleteAll", type="boolean", example=true) - * ), - * @OA\Schema( - * required={"files"}, - * @OA\Property( - * property="files", - * type="array", - * @OA\Items(type="string", example="trashedfile_1234567890") - * ) - * ) - * } - * ) - * ), - * @OA\Response( - * response=200, - * description="Trash items deleted successfully", - * @OA\JsonContent( - * @OA\Property(property="deleted", type="array", @OA\Items(type="string")) - * ) - * ), - * @OA\Response( - * response=400, - * description="Invalid input" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token" - * ) - * ) - * - * Deletes trash files by processing provided trash file identifiers. - * - * @return void Outputs a JSON response. - */ - public function deleteTrashFiles() - { - header('Content-Type: application/json'); +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; } - // 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; - } + $data = $this->_readJsonBody(); + if (!$data) { $this->_jsonOut(["error" => "Invalid input"], 400); return; } - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } - - // Read and decode JSON input. - $data = json_decode(file_get_contents("php://input"), true); - if (!$data) { - http_response_code(400); - echo json_encode(["error" => "Invalid input"]); - exit; - } - - // Determine deletion mode. $filesToDelete = []; - if (isset($data['deleteAll']) && $data['deleteAll'] === true) { - // In this case, we need to delete all trash items. - // Load current trash metadata. + if (!empty($data['deleteAll'])) { $trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; $shareFile = $trashDir . "trash.json"; if (file_exists($shareFile)) { - $json = file_get_contents($shareFile); - $tempData = json_decode($json, true); - if (is_array($tempData)) { - foreach ($tempData as $item) { - if (isset($item['trashName'])) { - $filesToDelete[] = $item['trashName']; - } + $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 { - http_response_code(400); - echo json_encode(["error" => "No trash file identifiers provided"]); - exit; + $this->_jsonOut(["error" => "No trash file identifiers provided"], 400); return; } - // Delegate deletion to the model. $result = FileModel::deleteTrashFiles($filesToDelete); - - // 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']); - echo json_encode(["success" => $msg]); + $msg = "Trash item".(count($result['deleted']) === 1 ? "" : "s")." deleted: ".implode(", ", $result['deleted']); + $this->_jsonOut(["success"=>$msg]); } elseif (!empty($result['error'])) { - echo json_encode(["error" => $result['error']]); + $this->_jsonOut(["error"=>$result['error']], 400); } else { - echo json_encode(["success" => "No items to delete."]); + $this->_jsonOut(["success"=>"No items to delete."]); } - exit; - } + } catch (Throwable $e) { + error_log('FileController::deleteTrashFiles error: '.$e->getMessage()); + $this->_jsonOut(['error' => 'Internal server error while deleting trash files.'], 500); + } finally { $this->_jsonEnd(); } +} - /** - * @OA\Get( - * path="/api/file/getFileTag.php", - * summary="Retrieve file tags", - * description="Retrieves tags from the createdTags.json metadata file.", - * operationId="getFileTags", - * tags={"Files"}, - * @OA\Response( - * response=200, - * description="File tags retrieved successfully", - * @OA\JsonContent( - * type="array", - * @OA\Items(type="object") - * ) - * ) - * ) - * - * Retrieves file tags from the createdTags.json metadata file. - * - * @return void Outputs JSON response with file tags. - */ public function getFileTags(): void { header('Content-Type: application/json; charset=utf-8'); - $tags = FileModel::getFileTags(); echo json_encode($tags); exit; } - /** - * @OA\Post( - * path="/api/file/saveFileTag.php", - * summary="Save file tags", - * description="Saves tag data for a specified file and updates global tag data. For folder-specific tags, saves to the folder's metadata file.", - * operationId="saveFileTag", - * tags={"Files"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"file", "tags"}, - * @OA\Property(property="file", type="string", example="document.txt"), - * @OA\Property(property="folder", type="string", example="Documents"), - * @OA\Property( - * property="tags", - * type="array", - * @OA\Items( - * type="object", - * @OA\Property(property="name", type="string", example="Important"), - * @OA\Property(property="color", type="string", example="#FF0000") - * ) - * ), - * @OA\Property(property="deleteGlobal", type="boolean", example=false), - * @OA\Property(property="tagToDelete", type="string", example="OldTag") - * ) - * ), - * @OA\Response( - * response=200, - * description="Tag data saved successfully", - * @OA\JsonContent( - * @OA\Property(property="success", type="string", example="Tag data saved successfully."), - * @OA\Property(property="globalTags", type="array", @OA\Items(type="object")) - * ) - * ), - * @OA\Response( - * response=400, - * description="Invalid request data" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token or insufficient permissions" - * ) - * ) - * - * Saves tag data for a file and updates the global tag repository. - * - * @return void Outputs JSON response. - */ public function saveFileTag(): void { - header("Cache-Control: no-cache, no-store, must-revalidate"); - header("Pragma: no-cache"); - header("Expires: 0"); - header('Content-Type: application/json'); + $this->_jsonStart(); + try { + if (!$this->_checkCsrf()) return; + if (!$this->_requireAuth()) return; - // CSRF Protection. - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $csrfHeader = $headersArr['x-csrf-token'] ?? ''; - if (!isset($_SESSION['csrf_token']) || trim($csrfHeader) !== $_SESSION['csrf_token']) { - http_response_code(403); - echo json_encode(["error" => "Invalid CSRF token"]); - exit; - } + $data = $this->_readJsonBody(); + if (!$data) { $this->_jsonOut(["error" => "No data received"], 400); return; } - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } + $file = trim((string)($data['file'] ?? '')); + $folder = $this->_normalizeFolder($data['folder'] ?? 'root'); + $tags = $data['tags'] ?? []; + $deleteGlobal= !empty($data['deleteGlobal']); + $tagToDelete = isset($data['tagToDelete']) ? trim((string)$data['tagToDelete']) : null; - // Check that the user is not read-only. - $username = $_SESSION['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; - } + if ($file === '' || !$this->_validFile($file)) { $this->_jsonOut(["error"=>"Invalid file."], 400); return; } + if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; } - // Retrieve and sanitize input. - $data = json_decode(file_get_contents('php://input'), true); - if (!$data) { - http_response_code(400); - echo json_encode(["error" => "No data received"]); - exit; - } + $username = $_SESSION['username'] ?? ''; + $userPermissions = $this->loadPerms($username); - $file = isset($data['file']) ? trim($data['file']) : ''; - $folder = isset($data['folder']) ? trim($data['folder']) : 'root'; - $tags = $data['tags'] ?? []; - $deleteGlobal = isset($data['deleteGlobal']) ? (bool)$data['deleteGlobal'] : false; - $tagToDelete = isset($data['tagToDelete']) ? trim($data['tagToDelete']) : null; + if (!ACL::canWrite($username, $userPermissions, $folder)) { + $this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return; + } - if ($file === '') { - http_response_code(400); - echo json_encode(["error" => "No file specified."]); - exit; - } + $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) { + $this->_jsonOut(["error" => "Forbidden: you are not the owner of this file."], 403); return; + } + } - // Validate folder name. - if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { - http_response_code(400); - echo json_encode(["error" => "Invalid folder name."]); - 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); + $result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete); + $this->_jsonOut($result); + } catch (Throwable $e) { + error_log('FileController::saveFileTag error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); + $this->_jsonOut(['error' => 'Internal server error while saving tags.'], 500); + } finally { $this->_jsonEnd(); } } - /** - * @OA\Get( - * path="/api/file/getFileList.php", - * summary="Get file list", - * description="Retrieves a list of files from a specified folder along with global tags and metadata.", - * operationId="getFileList", - * tags={"Files"}, - * @OA\Parameter( - * name="folder", - * in="query", - * description="Folder name (defaults to 'root')", - * required=false, - * @OA\Schema(type="string", example="Documents") - * ), - * @OA\Response( - * response=200, - * description="File list retrieved successfully", - * @OA\JsonContent( - * type="object", - * @OA\Property(property="files", type="array", @OA\Items(type="object")), - * @OA\Property(property="globalTags", type="array", @OA\Items(type="object")) - * ) - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=400, - * description="Bad Request" - * ) - * ) - * - * Retrieves the file list and associated metadata for the specified folder. - * - * @return void Outputs JSON response. - */ public function getFileList(): void { - if (session_status() !== PHP_SESSION_ACTIVE) { - session_start(); - } - + 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); @@ -1830,12 +845,9 @@ if (!$ignoreOwnership) { 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.']); @@ -1848,8 +860,20 @@ if (!$ignoreOwnership) { return; } - $result = FileModel::getFileList($folder); + // ---- 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'); + if (!$fullView && !$ownOnlyGrant) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden: no view access to this folder.']); + return; + } + + // Fetch the list + $result = FileModel::getFileList($folder); if ($result === false || $result === null) { http_response_code(500); echo json_encode(['error' => 'File model failed.']); @@ -1864,37 +888,35 @@ if (!$ignoreOwnership) { return; } - // --- 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'])) { + // ---- 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)) { - // associative: name => meta $filtered = []; foreach ($files as $name => $meta) { - if (!isset($meta['uploader']) || strcasecmp((string)$meta['uploader'], $username) === 0) { + // SAFETY: only include when uploader is present AND matches + 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; - })); + } + // 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); - return; - } catch (Throwable $e) { - error_log('FileController::getFileList error: ' . $e->getMessage() . - ' in ' . $e->getFile() . ':' . $e->getLine()); + 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 { @@ -1902,9 +924,6 @@ if (!$ignoreOwnership) { } } - /** - * GET /api/file/getShareLinks.php - */ public function getShareLinks() { header('Content-Type: application/json'); @@ -1921,84 +940,61 @@ if (!$ignoreOwnership) { : []; $now = time(); $cleaned = []; - - // remove expired + foreach ($links as $token => $record) { - if (!empty($record['expires']) && $record['expires'] < $now) { - continue; - } + if (!empty($record['expires']) && $record['expires'] < $now) continue; $cleaned[$token] = $record; } - + if (count($cleaned) !== count($links)) { file_put_contents($shareFile, json_encode($cleaned, JSON_PRETTY_PRINT)); } - + echo json_encode($cleaned); } - /** - * POST /api/file/deleteShareLink.php - */ public function deleteShareLink() { header('Content-Type: application/json'); $token = $_POST['token'] ?? ''; - if (!$token) { - echo json_encode(['success' => false, 'error' => 'No token provided']); - return; - } + if (!$token) { echo json_encode(['success' => false, 'error' => 'No token provided']); return; } $deleted = FileModel::deleteShareLink($token); - if ($deleted) { - echo json_encode(['success' => true]); - } else { - echo json_encode(['success' => false, 'error' => 'Not found']); - } + echo json_encode($deleted ? ['success' => true] : ['success' => false, 'error' => 'Not found']); } - /** - * POST /api/file/createFile.php - */ public function createFile(): void -{ - $this->_jsonStart(); - try { - if (!$this->_requireAuth()) return; + { + $this->_jsonStart(); + try { + if (!$this->_requireAuth()) return; - $username = $_SESSION['username'] ?? ''; - $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 = $this->_readJsonBody(); + $folder = $this->_normalizeFolder($body['folder'] ?? 'root'); + $filename = basename(trim((string)($body['name'] ?? ''))); - $body = $this->_readJsonBody(); - $folder = isset($body['folder']) ? trim((string)$body['folder']) : 'root'; - $filename = isset($body['name']) ? basename(trim((string)$body['name'])) : ''; + if (!$this->_validFolder($folder)) { $this->_jsonOut(["error" => "Invalid folder name."], 400); return; } + if (!$this->_validFile($filename)) { $this->_jsonOut(["error" => "Invalid file name."], 400); return; } - 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; - } + $username = $_SESSION['username'] ?? ''; + $userPermissions = $this->loadPerms($username); - $dv = $this->enforceFolderScope($folder, $username, $userPermissions); - if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } + if (!ACL::canWrite($username, $userPermissions, $folder)) { + $this->_jsonOut(["error"=>"Forbidden: no write access"], 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]); + $dv = $this->enforceFolderScope($folder, $username, $userPermissions); + if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } - } 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(); + $result = FileModel::createFile($folder, $filename, $username); + if (empty($result['success'])) { + $this->_jsonOut(['success'=>false,'error'=>$result['error'] ?? 'Failed to create file'], $result['code'] ?? 400); + return; + } + $this->_jsonOut(['success'=>true]); + } catch (Throwable $e) { + error_log('FileController::createFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); + $this->_jsonOut(['error' => 'Internal server error while creating file.'], 500); + } finally { $this->_jsonEnd(); } } -} } \ No newline at end of file diff --git a/src/controllers/FolderController.php b/src/controllers/FolderController.php index 65495a6..8e2bd7d 100644 --- a/src/controllers/FolderController.php +++ b/src/controllers/FolderController.php @@ -3,13 +3,19 @@ require_once __DIR__ . '/../../config/config.php'; require_once PROJECT_ROOT . '/src/models/FolderModel.php'; +require_once PROJECT_ROOT . '/src/models/UserModel.php'; +require_once PROJECT_ROOT . '/src/lib/ACL.php'; class FolderController { - // ---- Helpers ----------------------------------------------------------- + /* -------------------- Session / Header helpers -------------------- */ + private static function ensureSession(): void + { + if (session_status() !== PHP_SESSION_ACTIVE) session_start(); + } + private static function getHeadersLower(): array { - // getallheaders() may not exist on some SAPIs if (function_exists('getallheaders')) { $h = getallheaders(); if (is_array($h)) return array_change_key_case($h, CASE_LOWER); @@ -26,7 +32,8 @@ class FolderController private static function requireCsrf(): void { - $headers = self::getHeadersLower(); + self::ensureSession(); + $headers = self::getHeadersLower(); $received = trim($headers['x-csrf-token'] ?? ($_POST['csrfToken'] ?? '')); if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) { http_response_code(403); @@ -38,6 +45,7 @@ class FolderController private static function requireAuth(): void { + self::ensureSession(); if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); header('Content-Type: application/json'); @@ -46,11 +54,52 @@ class FolderController } } + /* -------------------- Permissions helpers -------------------- */ + private static 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 []; + } + + private static function getPerms(): array + { + self::ensureSession(); + $u = $_SESSION['username'] ?? ''; + return $u ? self::loadPerms($u) : []; + } + + private static function isAdmin(array $perms = []): bool + { + self::ensureSession(); + if (!empty($_SESSION['isAdmin'])) return true; + if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true; + + // Fallback: role from users.txt (role "1" means admin) + $u = $_SESSION['username'] ?? ''; + if ($u && class_exists('userModel') && method_exists('userModel', 'getUserRole')) { + $roleStr = userModel::getUserRole($u); + if ($roleStr === '1') return true; + } + return false; + } + private static function requireNotReadOnly(): void { - $username = $_SESSION['username'] ?? ''; - $perms = loadUserPermissions($username); - if ($username && !empty($perms['readOnly'])) { + $perms = self::getPerms(); + if (!empty($perms['readOnly'])) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['error' => 'Read-only users are not allowed to perform this action.']); @@ -60,7 +109,8 @@ class FolderController private static function requireAdmin(): void { - if (empty($_SESSION['isAdmin'])) { + $perms = self::getPerms(); + if (!self::isAdmin($perms)) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['error' => 'Admin privileges required.']); @@ -70,170 +120,116 @@ class FolderController private static function formatBytes(int $bytes): string { - if ($bytes < 1024) { - return $bytes . " B"; - } elseif ($bytes < 1048576) { - return round($bytes / 1024, 2) . " KB"; - } elseif ($bytes < 1073741824) { - return round($bytes / 1048576, 2) . " MB"; - } else { - return round($bytes / 1073741824, 2) . " GB"; - } + if ($bytes < 1024) return $bytes . " B"; + if ($bytes < 1048576) return round($bytes / 1024, 2) . " KB"; + if ($bytes < 1073741824) return round($bytes / 1048576, 2) . " MB"; + return round($bytes / 1073741824, 2) . " GB"; } - private function enforceFolderScope(string $folder, string $username, array $userPermissions): ?string { - if ($this->isAdmin($userPermissions) || !empty($userPermissions['bypassFolderScope'])) return null; - if (!$this->isFolderOnly($userPermissions)) return null; - - $folder = trim($folder); - if ($folder !== '' && strtolower($folder) !== 'root') { - if ($folder !== $username && strpos($folder, $username . '/') !== 0) { - return "Forbidden: folder scope violation."; - } - } - return null; - } - - /** - * @OA\Post( - * path="/api/folder/createFolder.php", - * summary="Create a new folder", - * description="Creates a new folder in the upload directory (under an optional parent) and creates an associated empty metadata file.", - * operationId="createFolder", - * tags={"Folders"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"folderName"}, - * @OA\Property(property="folderName", type="string", example="NewFolder"), - * @OA\Property(property="parent", type="string", example="Documents") - * ) - * ), - * @OA\Response( - * response=200, - * description="Folder created successfully", - * @OA\JsonContent( - * @OA\Property(property="success", type="boolean", example=true) - * ) - * ), - * @OA\Response( - * response=400, - * description="Bad Request (e.g., invalid folder name)" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token or permission denied" - * ) - * ) - */ - public function createFolder(): void + /** 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 { - header('Content-Type: application/json'); + if (self::isAdmin($perms)) return null; - self::requireAuth(); - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - http_response_code(405); - echo json_encode(['error' => 'Method not allowed.']); - exit; - } - self::requireCsrf(); - self::requireNotReadOnly(); + $folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']); + if (!$folderOnly) return null; - $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; + $folder = trim($folder); + if ($folder === '' || strcasecmp($folder, 'root') === 0) { + return "Forbidden: non-admins may not operate on the root folder."; } - $folderName = trim($input['folderName']); - $parent = isset($input['parent']) ? trim($input['parent']) : ""; - - if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { - http_response_code(400); - echo json_encode(['error' => 'Invalid folder name.']); - exit; - } - if ($parent && !preg_match(REGEX_FOLDER_NAME, $parent)) { - http_response_code(400); - echo json_encode(['error' => 'Invalid parent folder name.']); - exit; + if ($folder === $username || strpos($folder, $username . '/') === 0) { + return null; } + return "Forbidden: folder scope violation."; + } - $result = FolderModel::createFolder($folderName, $parent); - echo json_encode($result); + /** Returns true if caller can ignore ownership (admin or bypassOwnership/default). */ + private static function canBypassOwnership(array $perms): bool + { + if (self::isAdmin($perms)) return true; + 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 + { + 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; + } + + /* -------------------- 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(); + + $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']) : ''; + + 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; + + $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.']); exit; } - /** - * @OA\Post( - * path="/api/folder/deleteFolder.php", - * summary="Delete an empty folder", - * description="Deletes a specified folder if it is empty and not the root folder, and also removes its metadata file.", - * operationId="deleteFolder", - * tags={"Folders"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"folder"}, - * @OA\Property(property="folder", type="string", example="Documents/Subfolder") - * ) - * ), - * @OA\Response( - * response=200, - * description="Folder deleted successfully", - * @OA\JsonContent( - * @OA\Property(property="success", type="boolean", example=true) - * ) - * ), - * @OA\Response( - * response=400, - * description="Bad Request (e.g., invalid folder name or folder not empty)" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token or permission denied" - * ) - * ) - */ + // 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 { header('Content-Type: application/json'); - self::requireAuth(); - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - http_response_code(405); - echo json_encode(["error" => "Method not allowed."]); - exit; - } + 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['folder'])) { - http_response_code(400); - echo json_encode(["error" => "Folder name not provided."]); - exit; - } + if (!isset($input['folder'])) { http_response_code(400); echo json_encode(["error" => "Folder name not provided."]); exit; } $folder = trim($input['folder']); - if (strtolower($folder) === 'root') { - http_response_code(400); - echo json_encode(["error" => "Cannot delete root folder."]); - exit; - } - if (!preg_match(REGEX_FOLDER_NAME, $folder)) { - http_response_code(400); - echo json_encode(["error" => "Invalid folder name."]); - exit; + 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)) { + http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the folder owner."]); exit; } $result = FolderModel::deleteFolder($folder); @@ -241,69 +237,35 @@ class FolderController exit; } - /** - * @OA\Post( - * path="/api/folder/renameFolder.php", - * summary="Rename a folder", - * description="Renames an existing folder and updates its associated metadata files.", - * operationId="renameFolder", - * tags={"Folders"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"oldFolder", "newFolder"}, - * @OA\Property(property="oldFolder", type="string", example="Documents/OldFolder"), - * @OA\Property(property="newFolder", type="string", example="Documents/NewFolder") - * ) - * ), - * @OA\Response( - * response=200, - * description="Folder renamed successfully", - * @OA\JsonContent( - * @OA\Property(property="success", type="boolean", example=true) - * ) - * ), - * @OA\Response( - * response=400, - * description="Invalid folder names or folder does not exist" - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=403, - * description="Invalid CSRF token or permission denied" - * ) - * ) - */ + /* -------------------- API: Rename Folder -------------------- */ public function renameFolder(): 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; - } + 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['oldFolder']) || !isset($input['newFolder'])) { - http_response_code(400); - echo json_encode(['error' => 'Required folder names not provided.']); - exit; + http_response_code(400); echo json_encode(['error' => 'Required folder names not provided.']); exit; } $oldFolder = trim($input['oldFolder']); $newFolder = trim($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; + http_response_code(400); echo json_encode(['error' => 'Invalid folder name(s).']); exit; + } + + $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; } + + if (!self::canBypassOwnership($perms) && !self::isFolderOwner($oldFolder, $username)) { + http_response_code(403); echo json_encode(['error' => 'Forbidden: you are not the folder owner.']); exit; } $result = FolderModel::renameFolder($oldFolder, $newFolder); @@ -311,102 +273,69 @@ class FolderController exit; } - /** - * @OA\Get( - * path="/api/folder/getFolderList.php", - * summary="Get list of folders", - * description="Retrieves the list of folders in the upload directory, including file counts and metadata file names for each folder.", - * operationId="getFolderList", - * tags={"Folders"}, - * @OA\Parameter( - * name="folder", - * in="query", - * description="Optional folder name to filter the listing", - * required=false, - * @OA\Schema(type="string", example="Documents") - * ), - * @OA\Response( - * response=200, - * description="Folder list retrieved successfully", - * @OA\JsonContent( - * type="array", - * @OA\Items(type="object") - * ) - * ), - * @OA\Response( - * response=401, - * description="Unauthorized" - * ), - * @OA\Response( - * response=400, - * description="Bad request" - * ) - * ) - */ + /* -------------------- API: Get Folder List -------------------- */ public function getFolderList(): void - { - header('Content-Type: application/json'); - self::requireAuth(); +{ + header('Content-Type: application/json'); + self::requireAuth(); - $parent = $_GET['folder'] ?? null; - if ($parent !== null && $parent !== '' && $parent !== 'root' && !preg_match(REGEX_FOLDER_NAME, $parent)) { + // 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); + } - $folderList = FolderModel::getFolderList($parent); - echo json_encode($folderList); + $username = $_SESSION['username'] ?? ''; + $perms = loadUserPermissions($username) ?: []; + $isAdmin = self::isAdmin($perms); + + // 1) full list from model + $all = FolderModel::getFolderList(); // each row: ["folder","fileCount","metadataFile"] + if (!is_array($all)) { + echo json_encode([]); exit; } - /** - * @OA\Get( - * path="/api/folder/shareFolder.php", - * summary="Display a shared folder", - * description="Renders an HTML view of a shared folder's contents. Supports password protection, file listing with pagination, and an upload container if uploads are allowed.", - * operationId="shareFolder", - * tags={"Folders"}, - * @OA\Parameter( - * name="token", - * in="query", - * description="The share token for the folder", - * required=true, - * @OA\Schema(type="string") - * ), - * @OA\Parameter( - * name="pass", - * in="query", - * description="The password if the folder is protected", - * required=false, - * @OA\Schema(type="string") - * ), - * @OA\Parameter( - * name="page", - * in="query", - * description="Page number for pagination", - * required=false, - * @OA\Schema(type="integer", example=1) - * ), - * @OA\Response( - * response=200, - * description="Shared folder displayed", - * @OA\MediaType(mediaType="text/html") - * ), - * @OA\Response( - * response=400, - * description="Invalid request" - * ), - * @OA\Response( - * response=403, - * description="Access forbidden (expired link or invalid password)" - * ), - * @OA\Response( - * response=404, - * description="Share folder not found" - * ) - * ) - */ + // 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 { $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); @@ -414,53 +343,21 @@ class FolderController $page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT); if ($page === false || $page < 1) $page = 1; - if (empty($token)) { - http_response_code(400); - header('Content-Type: application/json'); - echo json_encode(["error" => "Missing token."]); - exit; - } + if (empty($token)) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "Missing token."]); exit; } $data = FolderModel::getSharedFolderData($token, $providedPass, $page); if (isset($data['needs_password']) && $data['needs_password'] === true) { header("Content-Type: text/html; charset=utf-8"); ?> - - - - - - Enter Password - - - -
-

Folder Protected

-

This folder is protected by a password. Please enter the password to view its contents.

-
- - - - -
-
- - - +Enter Password + +

Folder Protected

This folder is protected by a password. Please enter the password to view its contents.

+
+ $data['error']]); - exit; - } + if (isset($data['error'])) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(["error" => $data['error']]); exit; } require_once PROJECT_ROOT . '/src/models/AdminModel.php'; $adminConfig = AdminModel::getConfig(); @@ -473,332 +370,142 @@ class FolderController $totalPages = $data['totalPages']; header("Content-Type: text/html; charset=utf-8"); ?> - - - - - Shared Folder: <?php echo htmlspecialchars($folderName, ENT_QUOTES, 'UTF-8'); ?> - - - - -
-

Shared Folder:

-
-
- + + + +Shared Folder: <?php echo htmlspecialchars($folderName, ENT_QUOTES, 'UTF-8'); ?> + + + +

Shared Folder:

+
+ +
+ +

This folder is empty.

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

This folder is empty.

- - - - - - - - - - - - - -
FilenameSize
- - - -
- -
- - - - - - -
-

Upload File - - ( max size) - -

-
- - -

- -
-
- -
- - - - - - - +
+

Upload File ( max size)

+
+ +

+
+
+ +
+ + + + + + "Invalid input."]); exit; } - $in = json_decode(file_get_contents("php://input"), true); - if (!$in || !isset($in['folder'])) { - http_response_code(400); - echo json_encode(["error" => "Invalid input."]); - exit; - } + $folder = trim($in['folder']); + $value = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60; + $unit = $in['expirationUnit'] ?? 'minutes'; + $password = $in['password'] ?? ''; + $allowUpload = intval($in['allowUpload'] ?? 0); - $folder = trim($in['folder']); - $value = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60; - $unit = $in['expirationUnit'] ?? 'minutes'; - $password = $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; } - // Basic folder name guard - if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { - http_response_code(400); - echo json_encode(["error" => "Invalid folder name."]); - exit; - } + $username = $_SESSION['username'] ?? ''; + $perms = self::getPerms(); + $isAdmin = self::isAdmin($perms); - // ---- Permissions ---- - $username = $_SESSION['username'] ?? ''; - $perms = loadUserPermissions($username) ?: []; + if (!self::canShare($perms)) { http_response_code(403); echo json_encode(["error" => "Sharing is not permitted for your account."]); exit; } - $isAdmin = !empty($perms['admin']) || !empty($perms['isAdmin']); - $canShare = $isAdmin || ($perms['canShare'] ?? (defined('DEFAULT_CAN_SHARE') ? DEFAULT_CAN_SHARE : true)); - if (!$canShare) { - http_response_code(403); - echo json_encode(["error" => "Sharing is not permitted for your account."]); - exit; - } - - // Folder-only scope: non-admins must stay inside their subtree and cannot share root - $folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']); - if (!$isAdmin && strcasecmp($folder, 'root') === 0) { - http_response_code(403); - echo json_encode(["error" => "Only admins may share the root folder."]); - exit; - } - if (!$isAdmin && $folderOnly && $folder !== 'root') { - if ($folder !== $username && strpos($folder, $username . '/') !== 0) { - http_response_code(403); - echo json_encode(["error" => "Forbidden: folder scope violation."]); - exit; + 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; } } - } - // Ownership check unless bypassOwnership - $ignoreOwnership = $isAdmin || ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); - if (!$ignoreOwnership) { - // Only checks top-level files (sharing UI lists top-level files only) - $metaFile = (strcasecmp($folder, 'root') === 0) - ? META_DIR . 'root_metadata.json' - : META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json'; - - $meta = (is_file($metaFile) ? json_decode(@file_get_contents($metaFile), true) : []) ?: []; - foreach ($meta as $fname => $m) { - if (($m['uploader'] ?? null) !== $username) { - http_response_code(403); - echo json_encode(["error" => "Forbidden: you don't own all files in this folder."]); - exit; - } + if (!self::canBypassOwnership($perms) && !self::isFolderOwner($folder, $username)) { + http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the owner of this folder."]); exit; } - } - // If user is not allowed to upload generally, block share-with-upload - if ($allowUpload === 1 && !empty($perms['disableUpload']) && !$isAdmin) { - http_response_code(403); - echo json_encode(["error" => "You cannot enable uploads on shared folders."]); + if ($allowUpload === 1 && !empty($perms['disableUpload']) && !$isAdmin) { + http_response_code(403); echo json_encode(["error" => "You cannot enable uploads on shared folders."]); exit; + } + + if ($value < 1) $value = 1; + switch ($unit) { + case 'seconds': $seconds = $value; break; + case 'hours': $seconds = $value * 3600; break; + case 'days': $seconds = $value * 86400; break; + case 'minutes': + default: $seconds = $value * 60; break; + } + $seconds = min($seconds, 31536000); + + $res = FolderModel::createShareFolderLink($folder, $seconds, $password, $allowUpload); + echo json_encode($res); exit; } - // Expiration seconds (cap at 1 year) - if ($value < 1) $value = 1; - switch ($unit) { - case 'seconds': $seconds = $value; break; - case 'hours': $seconds = $value * 3600; break; - case 'days': $seconds = $value * 86400; break; - case 'minutes': - default: $seconds = $value * 60; break; - } - $seconds = min($seconds, 31536000); - - // Create share link - $res = FolderModel::createShareFolderLink($folder, $seconds, $password, $allowUpload); - echo json_encode($res); - exit; -} - - /** - * @OA\Get( - * path="/api/folder/downloadSharedFile.php", - * summary="Download a file from a shared folder", - * description="Retrieves and serves a file from a shared folder based on a share token.", - * operationId="downloadSharedFile", - * tags={"Folders"}, - * @OA\Parameter( - * name="token", - * in="query", - * description="The share folder token", - * required=true, - * @OA\Schema(type="string") - * ), - * @OA\Parameter( - * name="file", - * in="query", - * description="The filename to download", - * required=true, - * @OA\Schema(type="string") - * ), - * @OA\Response( - * response=200, - * description="File served successfully", - * @OA\MediaType(mediaType="application/octet-stream") - * ), - * @OA\Response( - * response=400, - * description="Bad Request (missing parameters, invalid file name, etc.)" - * ), - * @OA\Response( - * response=403, - * description="Access forbidden (e.g., expired share link)" - * ), - * @OA\Response( - * response=404, - * description="File not found" - * ) - * ) - */ + /* -------------------- API: Download Shared File -------------------- */ public function downloadSharedFile(): void { $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); $file = filter_input(INPUT_GET, 'file', FILTER_SANITIZE_STRING); - if (empty($token) || empty($file)) { - http_response_code(400); - header('Content-Type: application/json'); - echo json_encode(["error" => "Missing token or file parameter."]); - exit; - } + if (empty($token) || empty($file)) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "Missing token or file parameter."]); exit; } - // Extra safety: enforce filename policy before delegating $basename = basename($file); - if (!preg_match(REGEX_FILE_NAME, $basename)) { - http_response_code(400); - header('Content-Type: application/json'); - echo json_encode(["error" => "Invalid file name."]); - exit; - } + if (!preg_match(REGEX_FILE_NAME, $basename)) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "Invalid file name."]); exit; } $result = FolderModel::getSharedFileInfo($token, $basename); - if (isset($result['error'])) { - http_response_code(404); - header('Content-Type: application/json'); - echo json_encode(["error" => $result['error']]); - exit; - } + if (isset($result['error'])) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(["error" => $result['error']]); exit; } $realFilePath = $result['realFilePath']; $mimeType = $result['mimeType']; @@ -817,73 +524,21 @@ class FolderController exit; } - /** - * @OA\Post( - * path="/api/folder/uploadToSharedFolder.php", - * summary="Upload a file to a shared folder", - * description="Handles file upload to a shared folder using a share token. Validates file size, extension, and uploads the file to the shared folder, updating metadata accordingly.", - * operationId="uploadToSharedFolder", - * tags={"Folders"}, - * @OA\RequestBody( - * required=true, - * description="Multipart form data containing the share token and file to upload.", - * @OA\MediaType( - * mediaType="multipart/form-data", - * @OA\Schema( - * required={"token", "fileToUpload"}, - * @OA\Property(property="token", type="string"), - * @OA\Property(property="fileToUpload", type="string", format="binary") - * ) - * ) - * ), - * @OA\Response( - * response=302, - * description="Redirects to the shared folder page on success." - * ), - * @OA\Response( - * response=400, - * description="Bad Request (missing token, file upload error, file type/size not allowed)" - * ), - * @OA\Response( - * response=403, - * description="Forbidden (share link expired or uploads not allowed)" - * ), - * @OA\Response( - * response=500, - * description="Server error during file move" - * ) - * ) - */ + /* -------------------- API: Upload to Shared Folder -------------------- */ public function uploadToSharedFolder(): void { - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - http_response_code(405); - header('Content-Type: application/json'); - echo json_encode(["error" => "Method not allowed."]); - exit; - } + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); header('Content-Type: application/json'); echo json_encode(["error" => "Method not allowed."]); exit; } - if (empty($_POST['token'])) { - http_response_code(400); - header('Content-Type: application/json'); - echo json_encode(["error" => "Missing share token."]); - exit; - } + if (empty($_POST['token'])) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "Missing share token."]); exit; } $token = trim($_POST['token']); - if (!isset($_FILES['fileToUpload'])) { - http_response_code(400); - header('Content-Type: application/json'); - echo json_encode(["error" => "No file was uploaded."]); - exit; - } + if (!isset($_FILES['fileToUpload'])) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "No file was uploaded."]); exit; } $fileUpload = $_FILES['fileToUpload']; - // Quick surface error mapping if (!empty($fileUpload['error']) && $fileUpload['error'] !== UPLOAD_ERR_OK) { $map = [ UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive.', - UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.', + UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive.', UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded.', UPLOAD_ERR_NO_FILE => 'No file was uploaded.', UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder.', @@ -891,19 +546,11 @@ class FolderController UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload.' ]; $msg = $map[$fileUpload['error']] ?? 'Upload error.'; - http_response_code(400); - header('Content-Type: application/json'); - echo json_encode(['error' => $msg]); - exit; + http_response_code(400); header('Content-Type: application/json'); echo json_encode(['error' => $msg]); exit; } $result = FolderModel::uploadToSharedFolder($token, $fileUpload); - if (isset($result['error'])) { - http_response_code(400); - header('Content-Type: application/json'); - echo json_encode($result); - exit; - } + if (isset($result['error'])) { http_response_code(400); header('Content-Type: application/json'); echo json_encode($result); exit; } $_SESSION['upload_message'] = "File uploaded successfully."; $redirectUrl = "/api/folder/shareFolder.php?token=" . urlencode($token); @@ -911,9 +558,7 @@ class FolderController exit; } - /** - * GET /api/folder/getShareFolderLinks.php - */ + /* -------------------- Admin: List/Delete Share Folder Links -------------------- */ public function getAllShareFolderLinks(): void { header('Content-Type: application/json'); @@ -921,16 +566,12 @@ class FolderController self::requireAdmin(); // exposing all share folder links is an admin operation $shareFile = META_DIR . 'share_folder_links.json'; - $links = file_exists($shareFile) - ? json_decode(file_get_contents($shareFile), true) ?? [] - : []; + $links = file_exists($shareFile) ? json_decode(file_get_contents($shareFile), true) ?? [] : []; $now = time(); $cleaned = []; foreach ($links as $token => $record) { - if (!empty($record['expires']) && $record['expires'] < $now) { - continue; - } + if (!empty($record['expires']) && $record['expires'] < $now) continue; $cleaned[$token] = $record; } @@ -941,9 +582,6 @@ class FolderController echo json_encode($cleaned); } - /** - * POST /api/folder/deleteShareFolderLink.php - */ public function deleteShareFolderLink() { header('Content-Type: application/json'); @@ -952,11 +590,7 @@ class FolderController self::requireCsrf(); $token = $_POST['token'] ?? ''; - if (!$token) { - http_response_code(400); - echo json_encode(['success' => false, 'error' => 'No token provided']); - return; - } + if (!$token) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'No token provided']); return; } $deleted = FolderModel::deleteShareFolderLink($token); if ($deleted) { @@ -966,4 +600,4 @@ class FolderController echo json_encode(['success' => false, 'error' => 'Not found']); } } -} +} \ No newline at end of file diff --git a/src/controllers/UploadController.php b/src/controllers/UploadController.php index 273b7b2..3a51bb5 100644 --- a/src/controllers/UploadController.php +++ b/src/controllers/UploadController.php @@ -2,6 +2,7 @@ // src/controllers/UploadController.php require_once __DIR__ . '/../../config/config.php'; +require_once PROJECT_ROOT . '/src/lib/ACL.php'; require_once PROJECT_ROOT . '/src/models/UploadModel.php'; class UploadController { @@ -72,69 +73,80 @@ class UploadController { */ public function handleUpload(): void { header('Content-Type: application/json'); - - // - // 1) CSRF – pull from header or POST fields - // - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); + + // ---- 1) CSRF (header or form field) ---- + $headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER); $received = ''; if (!empty($headersArr['x-csrf-token'])) { $received = trim($headersArr['x-csrf-token']); } elseif (!empty($_POST['csrf_token'])) { $received = trim($_POST['csrf_token']); } elseif (!empty($_POST['upload_token'])) { + // legacy alias $received = trim($_POST['upload_token']); } - // 1a) If it doesn’t match, soft-fail: send new token and let client retry if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) { - // regenerate + // Soft-fail so client can retry with refreshed token $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); - // tell client “please retry with this new token” http_response_code(200); echo json_encode([ - 'csrf_expired' => true, - 'csrf_token' => $_SESSION['csrf_token'] + 'csrf_expired' => true, + 'csrf_token' => $_SESSION['csrf_token'] ]); - exit; + return; } - // - // 2) Auth checks - // - if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + // ---- 2) Auth + account-level flags ---- + if (empty($_SESSION['authenticated'])) { http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit; - } - $userPerms = loadUserPermissions($_SESSION['username']); - if (!empty($userPerms['disableUpload'])) { - http_response_code(403); - echo json_encode(["error" => "Upload disabled for this user."]); - exit; + echo json_encode(['error' => 'Unauthorized']); + return; } - // - // 3) Delegate the actual file handling - // + $username = (string)($_SESSION['username'] ?? ''); + $userPerms = loadUserPermissions($username) ?: []; + $isAdmin = ACL::isAdmin($userPerms); + + // Admins should never be blocked by account-level "disableUpload" + if (!$isAdmin && !empty($userPerms['disableUpload'])) { + http_response_code(403); + echo json_encode(['error' => 'Upload disabled for this user.']); + return; + } + + // ---- 3) Folder-level WRITE permission (ACL) ---- + // Always require client to send the folder; fall back to GET if needed. + $folderParam = isset($_POST['folder']) ? (string)$_POST['folder'] : (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root'); + $targetFolder = ACL::normalizeFolder($folderParam); + + // Admins bypass folder canWrite checks + if (!$isAdmin && !ACL::canWrite($username, $userPerms, $targetFolder)) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']); + return; + } + + // ---- 4) Delegate to model (actual file/chunk processing) ---- + // (Optionally re-check in UploadModel before finalizing.) $result = UploadModel::handleUpload($_POST, $_FILES); - // - // 4) Respond - // + // ---- 5) Response ---- if (isset($result['error'])) { http_response_code(400); echo json_encode($result); - exit; + return; } if (isset($result['status'])) { + // e.g., {"status":"chunk uploaded"} echo json_encode($result); - exit; + return; } - // full‐upload redirect - $_SESSION['upload_message'] = "File uploaded successfully."; - exit; + echo json_encode([ + 'success' => 'File uploaded successfully', + 'newFilename' => $result['newFilename'] ?? null + ]); } /** @@ -175,25 +187,22 @@ class UploadController { */ public function removeChunks(): void { header('Content-Type: application/json'); - - // CSRF Protection: Validate token from POST data. + $receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : ''; - if ($receivedToken !== $_SESSION['csrf_token']) { + if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) { http_response_code(403); - echo json_encode(["error" => "Invalid CSRF token"]); - exit; + echo json_encode(['error' => 'Invalid CSRF token']); + return; } - - // Check that the folder parameter is provided. + if (!isset($_POST['folder'])) { http_response_code(400); - echo json_encode(["error" => "No folder specified"]); - exit; + echo json_encode(['error' => 'No folder specified']); + return; } - - $folder = $_POST['folder']; + + $folder = (string)$_POST['folder']; $result = UploadModel::removeChunks($folder); echo json_encode($result); - exit; } } \ No newline at end of file diff --git a/src/controllers/UserController.php b/src/controllers/UserController.php index 4d79239..4420147 100644 --- a/src/controllers/UserController.php +++ b/src/controllers/UserController.php @@ -60,16 +60,37 @@ class UserController /** 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; +{ + self::requireAuth(); + + // Prefer the session flag + $isAdmin = (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true); + + // Fallback: check the user’s role in storage (e.g., users.txt/DB) + if (!$isAdmin) { + $u = $_SESSION['username'] ?? ''; + if ($u) { + try { + // UserModel::getUserRole($u) should return '1' for admins + $isAdmin = (UserModel::getUserRole($u) === '1'); + if ($isAdmin) { + // Normalize session so downstream ACL checks see admin + $_SESSION['isAdmin'] = true; + } + } catch (\Throwable $e) { + // ignore and continue to deny + } } } + if (!$isAdmin) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Admin privileges required.']); + exit; + } +} + /** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */ private static function requireCsrf(): void { diff --git a/src/lib/ACL.php b/src/lib/ACL.php new file mode 100644 index 0000000..e7c3557 --- /dev/null +++ b/src/lib/ACL.php @@ -0,0 +1,347 @@ + &$rec) { + foreach (self::BUCKETS as $k) { + $before = $rec[$k] ?? []; + $rec[$k] = array_values(array_filter($before, fn($u) => strcasecmp((string)$u, $user) !== 0)); + if ($rec[$k] !== $before) $changed = true; + } + } + unset($rec); + + return $changed ? self::save($acl) : true; + } + + /** Load ACL fresh from disk, create/heal if needed. */ + private static function loadFresh(): array { + $path = self::path(); + + if (!is_file($path)) { + @mkdir(dirname($path), 0755, true); + $init = [ + 'folders' => [ + 'root' => [ + 'owners' => ['admin'], + 'read' => ['admin'], + 'write' => ['admin'], + 'share' => ['admin'], + 'read_own'=> [], // new bucket; empty by default + ], + ], + 'groups' => [], + ]; + @file_put_contents($path, json_encode($init, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX); + } + + $json = (string) @file_get_contents($path); + $data = json_decode($json, true); + if (!is_array($data)) $data = []; + + // Normalize shape + $data['folders'] = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : []; + $data['groups'] = isset($data['groups']) && is_array($data['groups']) ? $data['groups'] : []; + + // Ensure root exists and has all buckets + if (!isset($data['folders']['root']) || !is_array($data['folders']['root'])) { + $data['folders']['root'] = [ + 'owners' => ['admin'], + 'read' => ['admin'], + 'write' => ['admin'], + 'share' => ['admin'], + 'read_own' => [], + ]; + } else { + foreach (self::BUCKETS as $k) { + if (!isset($data['folders']['root'][$k]) || !is_array($data['folders']['root'][$k])) { + // sensible defaults: admin in the classic buckets, empty for read_own + $data['folders']['root'][$k] = ($k === 'read_own') ? [] : ['admin']; + } + } + } + + // Heal any folder records + $healed = false; + foreach ($data['folders'] as $folder => &$rec) { + if (!is_array($rec)) { $rec = []; $healed = true; } + foreach (self::BUCKETS as $k) { + $v = $rec[$k] ?? []; + if (!is_array($v)) { $v = []; $healed = true; } + $v = array_values(array_unique(array_map('strval', $v))); + if (($rec[$k] ?? null) !== $v) { $rec[$k] = $v; $healed = true; } + } + } + unset($rec); + + self::$cache = $data; + + // Persist back if we healed anything + if ($healed) { + @file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX); + } + + return $data; + } + + /** Persist ACL to disk and refresh cache. */ + private static function save(array $acl): bool { + $ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false; + if ($ok) self::$cache = $acl; + return $ok; + } + + /** Get a bucket list (owners/read/write/share/read_own) for a folder (explicit only). */ + private static function listFor(string $folder, string $key): array { + $acl = self::$cache ?? self::loadFresh(); + $f = $acl['folders'][$folder] ?? null; + return is_array($f[$key] ?? null) ? $f[$key] : []; + } + + /** Ensure a folder record exists (giving an initial owner). */ + public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void { + $folder = self::normalizeFolder($folder); + $acl = self::$cache ?? self::loadFresh(); + if (!isset($acl['folders'][$folder])) { + $acl['folders'][$folder] = [ + 'owners' => [$owner], + 'read' => [$owner], + 'write' => [$owner], + 'share' => [$owner], + 'read_own' => [], + ]; + self::save($acl); + } + } + + /** True if this request is admin. */ + public static function isAdmin(array $perms = []): bool { + if (!empty($_SESSION['isAdmin'])) return true; + if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true; + if (isset($perms['role']) && (string)$perms['role'] === '1') return true; + if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true; + // Optional: if you configured DEFAULT_ADMIN_USER, treat that username as admin + if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username']) + && strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) { + return true; + } + return false; + } + + /** Case-insensitive membership in a capability bucket. $cap: owner|owners|read|write|share|read_own */ + public static function hasGrant(string $user, string $folder, string $cap): bool { + $folder = self::normalizeFolder($folder); + $capKey = ($cap === 'owner') ? 'owners' : $cap; + $arr = self::listFor($folder, $capKey); + foreach ($arr as $u) { + if (strcasecmp((string)$u, $user) === 0) return true; + } + return false; + } + + /** True if user is an explicit owner (or admin). */ + public static function isOwner(string $user, array $perms, string $folder): bool { + if (self::isAdmin($perms)) return true; + return self::hasGrant($user, $folder, 'owners'); + } + + /** "Manage" in UI == owner. */ + public static function canManage(string $user, array $perms, string $folder): bool { + return self::isOwner($user, $perms, $folder); + } + + public static function canRead(string $user, array $perms, string $folder): bool { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + // IMPORTANT: write no longer implies read + return self::hasGrant($user, $folder, 'owners') + || self::hasGrant($user, $folder, 'read'); + } + + /** Own-only view = read_own OR (any full view). */ + public static function canReadOwn(string $user, array $perms, string $folder): bool { + // if they can full-view, this is trivially true + if (self::canRead($user, $perms, $folder)) return true; + return self::hasGrant($user, $folder, 'read_own'); + } + + /** Upload = write OR owner. No bypassOwnership. */ + public static function canWrite(string $user, array $perms, string $folder): bool { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + return self::hasGrant($user, $folder, 'owners') + || self::hasGrant($user, $folder, 'write'); + } + + /** Share = share OR owner. No bypassOwnership. */ + public static function canShare(string $user, array $perms, string $folder): bool { + $folder = self::normalizeFolder($folder); + if (self::isAdmin($perms)) return true; + return self::hasGrant($user, $folder, 'owners') + || self::hasGrant($user, $folder, 'share'); + } + + /** + * Return explicit lists for a folder (no inheritance). + * Keys: owners, read, write, share, read_own (always arrays). + */ + public static function explicit(string $folder): array { + $folder = self::normalizeFolder($folder); + $acl = self::$cache ?? self::loadFresh(); + $rec = $acl['folders'][$folder] ?? []; + $norm = function ($v): array { + if (!is_array($v)) return []; + $v = array_map('strval', $v); + return array_values(array_unique($v)); + }; + return [ + 'owners' => $norm($rec['owners'] ?? []), + 'read' => $norm($rec['read'] ?? []), + 'write' => $norm($rec['write'] ?? []), + 'share' => $norm($rec['share'] ?? []), + 'read_own' => $norm($rec['read_own'] ?? []), + ]; + } + + /** + * Upsert a full explicit record for a folder. + * NOTE: preserves existing 'read_own' so older callers don't wipe it. + */ + public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool { + $folder = self::normalizeFolder($folder); + $acl = self::$cache ?? self::loadFresh(); + $existing = $acl['folders'][$folder] ?? ['read_own' => []]; + $fmt = function (array $arr): array { + return array_values(array_unique(array_map('strval', $arr))); + }; + $acl['folders'][$folder] = [ + 'owners' => $fmt($owners), + 'read' => $fmt($read), + 'write' => $fmt($write), + 'share' => $fmt($share), + // preserve any own-only grants unless caller explicitly manages them elsewhere + 'read_own' => isset($existing['read_own']) && is_array($existing['read_own']) + ? array_values(array_unique(array_map('strval', $existing['read_own']))) + : [], + ]; + return self::save($acl); + } + + /** + * Atomic per-user update across many folders. + * $grants is like: + * [ + * "folderA" => ["view"=>true, "viewOwn"=>false, "upload"=>true, "manage"=>false, "share"=>false], + * "folderB" => ["view"=>false, "viewOwn"=>true, "upload"=>false, "manage"=>false, "share"=>false], + * ] + * If a folder is INCLUDED with all false, the user is removed from all its buckets. + * (If the frontend omits a folder entirely, this method leaves that folder unchanged.) + */ + public static function applyUserGrantsAtomic(string $user, array $grants): array { + $user = (string)$user; + $path = self::path(); + + $fh = @fopen($path, 'c+'); + if (!$fh) throw new RuntimeException('Cannot open ACL storage'); + if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); } + + try { + // Read current content + $raw = stream_get_contents($fh); + if ($raw === false) $raw = ''; + $acl = json_decode($raw, true); + if (!is_array($acl)) $acl = ['folders'=>[], 'groups'=>[]]; + if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = []; + if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = []; + + $changed = []; + + foreach ($grants as $folder => $caps) { + $ff = self::normalizeFolder((string)$folder); + if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) { + $acl['folders'][$ff] = ['owners'=>[], 'read'=>[], 'write'=>[], 'share'=>[], 'read_own'=>[]]; + } + $rec =& $acl['folders'][$ff]; + + // Remove user from all buckets first (idempotent) + foreach (self::BUCKETS as $k) { + $rec[$k] = array_values(array_filter( + array_map('strval', $rec[$k]), + fn($u) => strcasecmp($u, $user) !== 0 + )); + } + + $v = !empty($caps['view']); // full view + $vo = !empty($caps['viewOwn']); // own-only view + $u = !empty($caps['upload']); + $m = !empty($caps['manage']); + $s = !empty($caps['share']); + + // Implications + if ($m) { $v = true; $u = true; } // owner implies read+write + if ($u && !$v && !$vo) $vo = true; // upload needs at least own-only visibility + if ($s && !$v) $v = true; // sharing implies full read (can be relaxed if desired) + + // Add back per caps + if ($m) $rec['owners'][] = $user; + if ($v) $rec['read'][] = $user; + if ($vo) $rec['read_own'][]= $user; + if ($u) $rec['write'][] = $user; + if ($s) $rec['share'][] = $user; + + // De-dup + foreach (self::BUCKETS as $k) { + $rec[$k] = array_values(array_unique(array_map('strval', $rec[$k]))); + } + + $changed[] = $ff; + unset($rec); + } + + // Write back atomically + ftruncate($fh, 0); + rewind($fh); + $ok = fwrite($fh, json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== false; + if (!$ok) throw new RuntimeException('Write failed'); + + self::$cache = $acl; + return ['ok' => true, 'updated' => $changed]; + } finally { + fflush($fh); + flock($fh, LOCK_UN); + fclose($fh); + } + } +} \ No newline at end of file diff --git a/src/models/FileModel.php b/src/models/FileModel.php index 707b22f..e3be03c 100644 --- a/src/models/FileModel.php +++ b/src/models/FileModel.php @@ -2,6 +2,7 @@ // src/models/FileModel.php require_once PROJECT_ROOT . '/config/config.php'; +require_once __DIR__ . '/../../src/lib/ACL.php'; class FileModel { diff --git a/src/models/FolderModel.php b/src/models/FolderModel.php index e04a724..40d8120 100644 --- a/src/models/FolderModel.php +++ b/src/models/FolderModel.php @@ -2,9 +2,99 @@ // src/models/FolderModel.php require_once PROJECT_ROOT . '/config/config.php'; +require_once PROJECT_ROOT . '/src/lib/ACL.php'; class FolderModel { + /* ============================================================ + * Ownership mapping helpers (stored in META_DIR/folder_owners.json) + * ============================================================ */ + + /** Load the folder → owner map. */ + public static function getFolderOwners(): array + { + $f = FOLDER_OWNERS_FILE; + if (!file_exists($f)) return []; + $json = json_decode(@file_get_contents($f), true); + return is_array($json) ? $json : []; + } + + /** Persist the folder → owner map. */ + public static function saveFolderOwners(array $map): bool + { + return (bool) @file_put_contents(FOLDER_OWNERS_FILE, json_encode($map, JSON_PRETTY_PRINT), LOCK_EX); + } + + /** Set (or replace) the owner for a specific folder (relative path or 'root'). */ + public static function setOwnerFor(string $folder, string $owner): void + { + $key = trim($folder, "/\\ "); + $key = ($key === '' ? 'root' : $key); + $owners = self::getFolderOwners(); + $owners[$key] = $owner; + self::saveFolderOwners($owners); + } + + /** Get the owner for a folder (relative path or 'root'); returns null if unmapped. */ + public static function getOwnerFor(string $folder): ?string + { + $key = trim($folder, "/\\ "); + $key = ($key === '' ? 'root' : $key); + $owners = self::getFolderOwners(); + return $owners[$key] ?? null; + } + + /** Rename a single ownership key (old → new). */ + public static function renameOwnerKey(string $old, string $new): void + { + $old = trim($old, "/\\ "); + $new = trim($new, "/\\ "); + $owners = self::getFolderOwners(); + if (isset($owners[$old])) { + $owners[$new] = $owners[$old]; + unset($owners[$old]); + self::saveFolderOwners($owners); + } + } + + /** Remove ownership for a folder and all its descendants. */ + public static function removeOwnerForTree(string $folder): void + { + $folder = trim($folder, "/\\ "); + $owners = self::getFolderOwners(); + foreach (array_keys($owners) as $k) { + if ($k === $folder || strpos($k, $folder . '/') === 0) { + unset($owners[$k]); + } + } + self::saveFolderOwners($owners); + } + + /** Rename ownership keys for an entire subtree: old/... → new/... */ + public static function renameOwnersForTree(string $oldFolder, string $newFolder): void + { + $old = trim($oldFolder, "/\\ "); + $new = trim($newFolder, "/\\ "); + $owners = self::getFolderOwners(); + + $rebased = []; + foreach ($owners as $k => $v) { + if ($k === $old || strpos($k, $old . '/') === 0) { + $suffix = substr($k, strlen($old)); + // ensure no leading slash duplication + $suffix = ltrim($suffix, '/'); + $rebased[$new . ($suffix !== '' ? '/' . $suffix : '')] = $v; + } else { + $rebased[$k] = $v; + } + } + self::saveFolderOwners($rebased); + } + + /* ============================================================ + * Existing helpers + * ============================================================ */ + /** * 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 @@ -59,9 +149,7 @@ class FolderModel return [$real, $relative, null]; } - /** - * Build metadata file path for a given (relative) folder. - */ + /** Build metadata file path for a given (relative) folder. */ private static function getMetadataFilePath(string $folder): string { if (strtolower($folder) === 'root' || trim($folder) === '') { @@ -72,42 +160,67 @@ class FolderModel /** * Creates a folder under the specified parent (or in root) and creates an empty metadata file. + * Also records the creator as the owner (if a session user is available). */ - public static function createFolder(string $folderName, string $parent = ""): array + "Invalid folder name."]; + if ($folderName === '' || !preg_match(REGEX_FOLDER_NAME, $folderName)) { + return ['success' => false, 'error' => 'Invalid folder name', 'code' => 400]; + } + if ($parent !== '' && strcasecmp($parent, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parent)) { + return ['success' => false, 'error' => 'Invalid parent folder', 'code' => 400]; } - // Resolve parent path (root ok; nested ok) - [$parentReal, $parentRel, $err] = self::resolveFolderPath($parent === '' ? 'root' : $parent, true); - if ($err) return ["error" => $err]; + // Compute ACL key and filesystem path + $aclKey = ($parent === '' || strcasecmp($parent, 'root') === 0) ? $folderName : ($parent . '/' . $folderName); - $targetRel = ($parentRel === 'root') ? $folderName : ($parentRel . '/' . $folderName); - $targetDir = $parentReal . DIRECTORY_SEPARATOR . $folderName; + $base = rtrim(UPLOAD_DIR, '/\\'); + $path = ($parent === '' || strcasecmp($parent, 'root') === 0) + ? $base . DIRECTORY_SEPARATOR . $folderName + : $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $parent) . DIRECTORY_SEPARATOR . $folderName; - if (file_exists($targetDir)) { - return ["error" => "Folder already exists."]; + // Safety: stay inside UPLOAD_DIR + $realBase = realpath($base); + $realPath = $path; // may not exist yet + $parentDir = dirname($path); + if (!is_dir($parentDir) && !@mkdir($parentDir, 0775, true)) { + return ['success' => false, 'error' => 'Failed to create parent path', 'code' => 500]; } - if (!mkdir($targetDir, 0775, true)) { - return ["error" => "Failed to create folder."]; + if (is_dir($path)) { + // Idempotent: still ensure ACL record exists + ACL::ensureFolderRecord($aclKey, $creator ?: 'admin'); + return ['success' => true, 'folder' => $aclKey, 'alreadyExists' => true]; } - // 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."]; + if (!@mkdir($path, 0775, true)) { + return ['success' => false, 'error' => 'Failed to create folder', 'code' => 500]; } - return ["success" => true]; + // Seed ACL: owner/read/write/share -> creator; read_own empty + ACL::ensureFolderRecord($aclKey, $creator ?: 'admin'); + + return ['success' => true, 'folder' => $aclKey]; } +} /** * Deletes a folder if it is empty and removes its corresponding metadata. + * Also removes ownership mappings for this folder and all its descendants. */ public static function deleteFolder(string $folder): array { @@ -119,12 +232,12 @@ class FolderModel if ($err) return ["error" => $err]; // Prevent deletion if not empty. - $items = array_diff(scandir($real), array('.', '..')); + $items = array_diff(@scandir($real) ?: [], array('.', '..')); if (count($items) > 0) { return ["error" => "Folder is not empty."]; } - if (!rmdir($real)) { + if (!@rmdir($real)) { return ["error" => "Failed to delete folder."]; } @@ -134,11 +247,15 @@ class FolderModel @unlink($metadataFile); } + // Remove ownership mappings for the subtree. + self::removeOwnerForTree($relative); + return ["success" => true]; } /** * Renames a folder and updates related metadata files (by renaming their filenames). + * Also rewrites ownership keys for the whole subtree from old → new. */ public static function renameFolder(string $oldFolder, string $newFolder): array { @@ -163,6 +280,7 @@ class FolderModel if ($base === false) return ["error" => "Uploads directory not configured correctly."]; $newParts = array_filter(explode('/', $newFolder), fn($p) => $p!==''); + $newRel = implode('/', $newParts); $newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts); // Parent of new path must exist @@ -174,13 +292,13 @@ class FolderModel return ["error" => "New folder name already exists."]; } - if (!rename($oldReal, $newPath)) { + if (!@rename($oldReal, $newPath)) { return ["error" => "Failed to rename folder."]; } // Update metadata filenames (prefix-rename) $oldPrefix = str_replace(['/', '\\', ' '], '-', $oldRel); - $newPrefix = str_replace(['/', '\\', ' '], '-', implode('/', $newParts)); + $newPrefix = str_replace(['/', '\\', ' '], '-', $newRel); $globPat = META_DIR . $oldPrefix . '*_metadata.json'; $metadataFiles = glob($globPat) ?: []; @@ -191,6 +309,9 @@ class FolderModel @rename($oldMetaFile, $newMeta); } + // Update ownership mapping for the entire subtree. + self::renameOwnersForTree($oldRel, $newRel); + return ["success" => true]; } @@ -217,8 +338,9 @@ class FolderModel /** * Retrieves the list of folders (including "root") along with file count metadata. + * (Ownership filtering is handled in the controller; this function remains unchanged.) */ - public static function getFolderList(): array + public static function getFolderList($ignoredParent = null, ?string $username = null, array $perms = []): array { $baseDir = realpath(UPLOAD_DIR); if ($baseDir === false) { @@ -256,6 +378,12 @@ class FolderModel ]; } + if ($username !== null) { + $folderInfoList = array_values(array_filter( + $folderInfoList, + fn($row) => ACL::canRead($username, $perms, $row['folder']) + )); + } return $folderInfoList; } diff --git a/src/models/UserModel.php b/src/models/UserModel.php index 39ff17a..f180bad 100644 --- a/src/models/UserModel.php +++ b/src/models/UserModel.php @@ -81,63 +81,94 @@ class userModel * Remove a user and update encrypted userPermissions.json. */ public static function removeUser($usernameToRemove) - { - global $encryptionKey; +{ + 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) ?: []; - $newUsers = []; - $userFound = false; - - foreach ($existingUsers as $line) { - $parts = explode(':', trim($line)); - if (count($parts) < 3) { - continue; - } - if ($parts[0] === $usernameToRemove) { - $userFound = true; - continue; // skip - } - $newUsers[] = $line; - } - - if (!$userFound) { - return ["error" => "User not found"]; - } - - $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 *encrypted* userPermissions.json consistently - $permissionsFile = USERS_DIR . "userPermissions.json"; - if (file_exists($permissionsFile)) { - $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); - } - } - - return ["success" => "User removed successfully"]; + 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) ?: []; + $newUsers = []; + $userFound = false; + + foreach ($existingUsers as $line) { + $parts = explode(':', trim($line)); + if (count($parts) < 3) { + continue; + } + if (strcasecmp($parts[0], $usernameToRemove) === 0) { + $userFound = true; + continue; // skip this user + } + $newUsers[] = $line; + } + + if (!$userFound) { + return ["error" => "User not found"]; + } + + $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 encrypted userPermissions.json — remove any key matching case-insensitively + $permissionsFile = USERS_DIR . "userPermissions.json"; + if (file_exists($permissionsFile)) { + $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)) { + foreach (array_keys($permissionsArray) as $k) { + if (strcasecmp($k, $usernameToRemove) === 0) { + unset($permissionsArray[$k]); + } + } + $plain = json_encode($permissionsArray, JSON_PRETTY_PRINT); + $enc = encryptData($plain, $encryptionKey); + file_put_contents($permissionsFile, $enc, LOCK_EX); + } + } + + // Purge from ACL (remove from every bucket in every folder) + require_once PROJECT_ROOT . '/src/lib/ACL.php'; + if (method_exists('ACL', 'purgeUser')) { + ACL::purgeUser($usernameToRemove); + } else { + // Fallback inline purge if you haven't added ACL::purgeUser yet: + $aclPath = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json'; + $acl = is_file($aclPath) ? json_decode((string)file_get_contents($aclPath), true) : []; + if (!is_array($acl)) $acl = ['folders' => [], 'groups' => []]; + $buckets = ['owners','read','write','share','read_own']; + + $changed = false; + foreach ($acl['folders'] ?? [] as $f => &$rec) { + foreach ($buckets as $b) { + if (!isset($rec[$b]) || !is_array($rec[$b])) { $rec[$b] = []; continue; } + $before = $rec[$b]; + $rec[$b] = array_values(array_filter($rec[$b], fn($u) => strcasecmp((string)$u, $usernameToRemove) !== 0)); + if ($rec[$b] !== $before) $changed = true; + } + } + unset($rec); + + if ($changed) { + @file_put_contents($aclPath, json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX); + } + } + + return ["success" => "User removed successfully"]; +} + /** * Get permissions for current user (or all, if admin). */ @@ -188,7 +219,7 @@ class userModel if (file_exists($permissionsFile)) { $encryptedContent = file_get_contents($permissionsFile); $json = decryptData($encryptedContent, $encryptionKey); - if ($json === false) $json = $encryptedContent; // plain JSON fallback + if ($json === false) $json = $encryptedContent; // legacy plaintext $existingPermissions = json_decode($json, true) ?: []; } @@ -209,22 +240,34 @@ class userModel 'bypassOwnership','canShare','canZip','viewOwnOnly' ]; + // Build a map of lowercase->actual key to update existing entries case-insensitively + $lcIndex = []; + foreach ($existingPermissions as $k => $_) { + $lcIndex[strtolower($k)] = $k; + } + foreach ($permissions as $perm) { if (empty($perm['username'])) continue; - $uname = strtolower($perm['username']); - $role = $userRoles[$uname] ?? null; + + $unameOrig = (string)$perm['username']; // preserve original case + $unameLc = strtolower($unameOrig); + $role = $userRoles[$unameLc] ?? null; if ($role === "1") continue; // skip admins - $current = $existingPermissions[$uname] ?? []; + // Find existing key case-insensitively; otherwise use original case as canonical + $storeKey = $lcIndex[$unameLc] ?? $unameOrig; + + $current = $existingPermissions[$storeKey] ?? []; 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; + + $existingPermissions[$storeKey] = $current; + $lcIndex[$unameLc] = $storeKey; // keep index up to date } $plain = json_encode($existingPermissions, JSON_PRETTY_PRINT); diff --git a/src/webdav/FileRiseDirectory.php b/src/webdav/FileRiseDirectory.php index e4ee098..a93ff34 100644 --- a/src/webdav/FileRiseDirectory.php +++ b/src/webdav/FileRiseDirectory.php @@ -1,9 +1,9 @@ metadata array */ + private array $metaCache = []; /** - * @param string $path Absolute filesystem path (no trailing slash) - * @param string $user Authenticated username - * @param bool $folderOnly If true, non‑admins only see $path/{user} + * @param string $path Absolute filesystem path (no trailing slash) + * @param string $user Authenticated username + * @param bool $isAdmin + * @param array $perms user-permissions map (readOnly, disableUpload, bypassOwnership, etc.) */ - public function __construct(string $path, string $user, bool $folderOnly) { - $this->path = rtrim($path, '/\\'); - $this->user = $user; - $this->folderOnly = $folderOnly; + public function __construct(string $path, string $user, bool $isAdmin, array $perms) { + $this->path = rtrim($path, '/\\'); + $this->user = $user; + $this->isAdmin = $isAdmin; + $this->perms = $perms; } // ── INode ─────────────────────────────────────────── @@ -39,72 +42,185 @@ class FileRiseDirectory implements ICollection, INode { } public function getLastModified(): int { - return filemtime($this->path); + return @filemtime($this->path) ?: time(); } public function delete(): void { - throw new Forbidden('Cannot delete this node'); + throw new Forbidden('Cannot delete directories via WebDAV'); } public function setName($name): void { - throw new Forbidden('Renaming not supported'); + throw new Forbidden('Renaming directories is not supported'); } // ── ICollection ──────────────────────────────────── public function getChildren(): array { + // Determine “folder key” relative to UPLOAD_DIR for ACL checks + $folderKey = $this->folderKeyForPath($this->path); + + // Check view permission on *this* directory + $canFull = \ACL::canRead($this->user, $this->perms, $folderKey); + $canOwn = \ACL::hasGrant($this->user, $folderKey, 'read_own'); + if (!$this->isAdmin && !$canFull && !$canOwn) { + throw new Forbidden('No view access to this folder'); + } + $nodes = []; + $hide = ['trash','profile_pics']; // internal dirs to hide foreach (new \DirectoryIterator($this->path) as $item) { if ($item->isDot()) continue; + $name = $item->getFilename(); + if (in_array(strtolower($name), $hide, true)) continue; + $full = $item->getPathname(); + if ($item->isDir()) { - $nodes[] = new self($full, $this->user, $this->folderOnly); - } else { - $nodes[] = new FileRiseFile($full, $this->user); + // Decide if the *child folder* should be visible + $childKey = $this->folderKeyForPath($full); + $canChild = $this->isAdmin + || \ACL::canRead($this->user, $this->perms, $childKey) + || \ACL::hasGrant($this->user, $childKey, 'read_own'); + + if ($canChild) { + $nodes[] = new self($full, $this->user, $this->isAdmin, $this->perms); + } + continue; + } + + // File in this directory: only list if full-view OR (own-only AND owner) + if ($canFull || $this->fileIsOwnedByUser($folderKey, $name)) { + $nodes[] = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms); } } - // Apply folder‑only at the top level - if ( - $this->folderOnly - && realpath($this->path) === realpath(rtrim(UPLOAD_DIR,'/\\')) - ) { - $nodes = array_filter($nodes, fn(INode $n)=> $n->getName() === $this->user); - } + return array_values($nodes); } public function childExists($name): bool { - return file_exists($this->path . DIRECTORY_SEPARATOR . $name); + $full = $this->path . DIRECTORY_SEPARATOR . $name; + if (!file_exists($full)) return false; + + $folderKey = $this->folderKeyForPath($this->path); + $isDir = is_dir($full); + + if ($isDir) { + $childKey = $this->folderKeyForPath($full); + return $this->isAdmin + || \ACL::canRead($this->user, $this->perms, $childKey) + || \ACL::hasGrant($this->user, $childKey, 'read_own'); + } + + // file + $canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey); + if ($canFull) return true; + + return \ACL::hasGrant($this->user, $folderKey, 'read_own') + && $this->fileIsOwnedByUser($folderKey, $name); } public function getChild($name): INode { $full = $this->path . DIRECTORY_SEPARATOR . $name; if (!file_exists($full)) throw new NotFound("Not found: $name"); - return is_dir($full) - ? new self($full, $this->user, $this->folderOnly) - : new FileRiseFile($full, $this->user); + + $folderKey = $this->folderKeyForPath($this->path); + if (is_dir($full)) { + $childKey = $this->folderKeyForPath($full); + $canDir = $this->isAdmin + || \ACL::canRead($this->user, $this->perms, $childKey) + || \ACL::hasGrant($this->user, $childKey, 'read_own'); + if (!$canDir) throw new Forbidden('No view access to requested folder'); + return new self($full, $this->user, $this->isAdmin, $this->perms); + } + + // file + $canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey); + if (!$canFull) { + if (!\ACL::hasGrant($this->user, $folderKey, 'read_own') || !$this->fileIsOwnedByUser($folderKey, $name)) { + throw new Forbidden('No view access to requested file'); + } + } + return new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms); } public function createFile($name, $data = null): INode { + $folderKey = $this->folderKeyForPath($this->path); + + if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) { + throw new Forbidden('No write access to this folder'); + } + if (!empty($this->perms['disableUpload']) && !$this->isAdmin) { + throw new Forbidden('Uploads are disabled for your account'); + } + + // Write directly to FS, then ensure metadata via FileRiseFile::put() $full = $this->path . DIRECTORY_SEPARATOR . $name; $content = is_resource($data) ? stream_get_contents($data) : (string)$data; - // Compute folder‑key relative to UPLOAD_DIR - $rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1); - $parts = explode('/', str_replace('\\','/',$rel)); - $filename = array_pop($parts); - $folder = empty($parts) ? 'root' : implode('/', $parts); + // Let FileRiseFile handle metadata & overwrite semantics + $fileNode = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms); + $fileNode->put($content); - FileModel::saveFile($folder, $filename, $content, $this->user); - return new FileRiseFile($full, $this->user); + return $fileNode; } public function createDirectory($name): INode { - $full = $this->path . DIRECTORY_SEPARATOR . $name; - $rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1); + $parentKey = $this->folderKeyForPath($this->path); + if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $parentKey)) { + throw new Forbidden('No permission to create subfolders here'); + } + + $full = $this->path . DIRECTORY_SEPARATOR . $name; + if (!is_dir($full)) { + @mkdir($full, 0755, true); + } + + // FileRise folder bookkeeping (owner = creator) + $rel = $this->relFromUploads($full); $parent = dirname(str_replace('\\','/',$rel)); if ($parent === '.' || $parent === '/') $parent = ''; - FolderModel::createFolder($name, $parent, $this->user); - return new self($full, $this->user, $this->folderOnly); + \FolderModel::createFolder($name, $parent, $this->user); + + return new self($full, $this->user, $this->isAdmin, $this->perms); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private function folderKeyForPath(string $absPath): string { + $base = rtrim(UPLOAD_DIR, '/\\'); + $realBase = realpath($base) ?: $base; + $real = realpath($absPath) ?: $absPath; + + if (stripos($real, $realBase) !== 0) return 'root'; + $rel = ltrim(str_replace('\\','/', substr($real, strlen($realBase))), '/'); + return ($rel === '' ? 'root' : $rel); + } + + private function relFromUploads(string $absPath): string { + $base = rtrim(UPLOAD_DIR, '/\\'); + return ltrim(str_replace('\\','/', substr($absPath, strlen($base))), '/'); + } + + private function loadMeta(string $folderKey): array { + if (isset($this->metaCache[$folderKey])) return $this->metaCache[$folderKey]; + + $metaFile = META_DIR . ( + $folderKey === 'root' + ? 'root_metadata.json' + : str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json' + ); + + $data = []; + if (is_file($metaFile)) { + $decoded = json_decode(@file_get_contents($metaFile), true); + if (is_array($decoded)) $data = $decoded; + } + return $this->metaCache[$folderKey] = $data; + } + + private function fileIsOwnedByUser(string $folderKey, string $fileName): bool { + $meta = $this->loadMeta($folderKey); + return isset($meta[$fileName]['uploader']) + && strcasecmp((string)$meta[$fileName]['uploader'], $this->user) === 0; } } \ No newline at end of file diff --git a/src/webdav/FileRiseFile.php b/src/webdav/FileRiseFile.php index 99dc490..bf3e11d 100644 --- a/src/webdav/FileRiseFile.php +++ b/src/webdav/FileRiseFile.php @@ -5,18 +5,25 @@ namespace FileRise\WebDAV; require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../vendor/autoload.php'; +require_once __DIR__ . '/../../src/lib/ACL.php'; require_once __DIR__ . '/../../src/models/FileModel.php'; +require_once __DIR__ . '/CurrentUser.php'; use Sabre\DAV\IFile; use Sabre\DAV\INode; use Sabre\DAV\Exception\Forbidden; -use FileModel; class FileRiseFile implements IFile, INode { private string $path; + private string $user; + private bool $isAdmin; + private array $perms; - public function __construct(string $path) { - $this->path = $path; + public function __construct(string $path, string $user, bool $isAdmin, array $perms) { + $this->path = $path; + $this->user = $user; + $this->isAdmin = $isAdmin; + $this->perms = $perms; } // ── INode ─────────────────────────────────────────── @@ -26,90 +33,134 @@ class FileRiseFile implements IFile, INode { } public function getLastModified(): int { - return filemtime($this->path); + return @filemtime($this->path) ?: time(); } public function delete(): void { - $base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR; - $rel = substr($this->path, strlen($base)); - $parts = explode(DIRECTORY_SEPARATOR, $rel); - $file = array_pop($parts); - $folder = empty($parts) ? 'root' : $parts[0]; - FileModel::deleteFiles($folder, [$file]); + [$folderKey, $fileName] = $this->split(); + if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) { + throw new Forbidden('No write access to delete this file'); + } + if (!$this->canTouchOwnership($folderKey, $fileName)) { + throw new Forbidden('You do not own this file'); + } + \FileModel::deleteFiles($folderKey, [$fileName]); } public function setName($newName): void { - throw new Forbidden('Renaming files not supported'); + throw new Forbidden('Renaming files via WebDAV is not supported'); } // ── IFile ─────────────────────────────────────────── public function get() { + [$folderKey, $fileName] = $this->split(); + $canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey); + if (!$canFull) { + // own-only? + if (!\ACL::hasGrant($this->user, $folderKey, 'read_own') || !$this->isOwner($folderKey, $fileName)) { + throw new Forbidden('No view access to this file'); + } + } return fopen($this->path, 'rb'); } public function put($data): ?string { - // 1) Save incoming data + [$folderKey, $fileName] = $this->split(); + + if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) { + throw new Forbidden('No write access to this folder'); + } + if (!empty($this->perms['disableUpload']) && !$this->isAdmin) { + throw new Forbidden('Uploads are disabled for your account'); + } + + // If overwriting existing file, enforce ownership for non-admin unless bypassOwnership + $exists = is_file($this->path); + $bypass = !empty($this->perms['bypassOwnership']); + if ($exists && !$this->isAdmin && !$bypass && !$this->isOwner($folderKey, $fileName)) { + throw new Forbidden('You do not own the target file'); + } + + // Write data file_put_contents( $this->path, is_resource($data) ? stream_get_contents($data) : (string)$data ); - // 2) Update metadata with CurrentUser - $this->updateMetadata(); + // Update metadata (uploader on first write; modified every write) + $this->updateMetadata($folderKey, $fileName); - // 3) Flush to client fast if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } - return null; // no ETag } public function getSize(): int { - return filesize($this->path); + return @filesize($this->path) ?: 0; } public function getETag(): string { - return '"' . md5($this->getLastModified() . $this->getSize()) . '"'; + return '"' . md5(($this->getLastModified() ?: 0) . ':' . ($this->getSize() ?: 0)) . '"'; } public function getContentType(): ?string { - return mime_content_type($this->path) ?: null; + return @mime_content_type($this->path) ?: null; } - // ── Metadata helper ─────────────────────────────────── + // ── helpers ────────────────────────────────────────────────────────────── - private function updateMetadata(): void { - $base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR; - $rel = substr($this->path, strlen($base)); - $parts = explode(DIRECTORY_SEPARATOR, $rel); - $fileName = array_pop($parts); - $folder = empty($parts) ? 'root' : $parts[0]; + private function split(): array { + $base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR; + $rel = ltrim(str_replace('\\','/', substr($this->path, strlen($base))), '/'); + $parts = explode('/', $rel); + $file = array_pop($parts); + $folder = empty($parts) ? 'root' : implode('/', $parts); + return [$folder, $file]; + } - $metaFile = META_DIR - . ($folder === 'root' - ? 'root_metadata.json' - : str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json'); + private function metaFile(string $folderKey): string { + return META_DIR . ( + $folderKey === 'root' + ? 'root_metadata.json' + : str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json' + ); + } - $metadata = []; - if (file_exists($metaFile)) { - $decoded = json_decode(file_get_contents($metaFile), true); - if (is_array($decoded)) { - $metadata = $decoded; - } - } + private function loadMeta(string $folderKey): array { + $mf = $this->metaFile($folderKey); + if (!is_file($mf)) return []; + $d = json_decode(@file_get_contents($mf), true); + return is_array($d) ? $d : []; + } + private function saveMeta(string $folderKey, array $meta): void { + @file_put_contents($this->metaFile($folderKey), json_encode($meta, JSON_PRETTY_PRINT)); + } + + private function isOwner(string $folderKey, string $fileName): bool { + $meta = $this->loadMeta($folderKey); + return isset($meta[$fileName]['uploader']) && + strcasecmp((string)$meta[$fileName]['uploader'], $this->user) === 0; + } + + private function canTouchOwnership(string $folderKey, string $fileName): bool { + if ($this->isAdmin || !empty($this->perms['bypassOwnership'])) return true; + return $this->isOwner($folderKey, $fileName); + } + + private function updateMetadata(string $folderKey, string $fileName): void { + $meta = $this->loadMeta($folderKey); $now = date(DATE_TIME_FORMAT); - $uploaded = $metadata[$fileName]['uploaded'] ?? $now; - $uploader = CurrentUser::get(); + $uploaded = $meta[$fileName]['uploaded'] ?? $now; + $uploader = CurrentUser::get() ?: $this->user; - $metadata[$fileName] = [ - 'uploaded' => $uploaded, - 'modified' => $now, - 'uploader' => $uploader, + $meta[$fileName] = [ + 'uploaded' => $uploaded, + 'modified' => $now, + 'uploader' => $uploader, ]; - - file_put_contents($metaFile, json_encode($metadata, JSON_PRETTY_PRINT)); + $this->saveMeta($folderKey, $meta); } } \ No newline at end of file