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() {