diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ac2c8..f45d7a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Changes 10/22/2025 (v1.6.0) + +feat(acl): granular per-folder permissions + stricter gates; WebDAV & UI aligned + +- Add granular ACL buckets: create, upload, edit, rename, copy, move, delete, extract, share_file, share_folder +- Implement ACL::canX helpers and expand upsert/explicit APIs (preserve read_own) +- Enforce “write no longer implies read” in canRead; use granular gates for write-ish ops +- WebDAV: use canDelete for DELETE, canUpload/canEdit + disableUpload for PUT; enforce ownership on overwrite +- Folder create: require Manage/Owner on parent; normalize paths; seed ACL; rollback on failure +- FileController: refactor copy/move/rename/delete/extract to granular gates + folder-scope checks + own-only ownership enforcement +- Capabilities API: compute effective actions with scope + readOnly/disableUpload; protect root +- Admin Panel (v1.6.0): new Folder Access editor with granular caps, inheritance hints, bulk toggles, and UX validations +- getFileList: keep root visible but inert for users without visibility; apply own-only filtering server-side +- Bump version to v1.6.0 + +--- + ## Changes 10/20/2025 (v1.5.3) security(acl): enforce folder-scope & own-only; fix file list “Select All”; harden ops diff --git a/README.md b/README.md index 157c3b1..2ccf2d5 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,14 @@ **Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting) -**Elevate your File Management** – A modern, self-hosted web file manager. -Upload, organize, and share files or folders through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze. +**Elevate your File Management** – A modern, self-hosted web file manager. +Upload, organize, and share files or folders through a sleek, responsive web interface. +**FileRise** is lightweight yet powerful — your personal cloud drive that you fully control. + +Now featuring **Granular Access Control (ACL)** with per-folder permissions, inheritance, and live admin editing. +Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage* on a per-user, per-folder basis — enforced across the UI, API, and WebDAV. + +With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted. > ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade. @@ -26,33 +32,54 @@ Upload, organize, and share files or folders through a sleek web interface. **Fi ## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features) -- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with pause/resumable chunked uploads and shows real-time progress for each file. FileRise will pick up where it left off if your connection drops. +- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with resumable chunked uploads, pause/resume, and real-time progress. If your connection drops, FileRise resumes automatically. -- 🗂️ **File Management:** Full set of file/folder operations – move or copy files (via intuitive drag-drop or dialogs), rename items, and delete in batches. You can download selected files as a ZIP archive or extract uploaded ZIP files server-side. Organize content with an interactive folder tree and breadcrumb navigation for quick jumps. +- 🗂️ **File Management:** Full suite of operations — move/copy (via drag-drop or dialogs), rename, and batch delete. Download selected files as ZIPs or extract uploaded ZIPs server-side. Organize with an interactive folder tree and breadcrumbs for instant navigation. -- 🗃️ **Folder Sharing & File Sharing:** Share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items/page); file sizes are displayed in MB. Share individual files with one-time or expiring links (optional password protection). +- 🗃️ **Folder & File Sharing:** Share folders or individual files with expiring, optionally password-protected links. Shared folders can accept external uploads (if enabled). Listings are paginated (10 items/page) with file sizes shown in MB. -- 🔐 **Fine-grained Access Control (ACL):** Per-folder grants for **owners**, **read** (view all), **read_own** (own-only visibility), **write** (upload/edit), and **share**. - - _Note:_ **write no longer implies read**. Grant **read** if uploaders should see all files; or **read_own** for self-only listings. - - Enforced server-side across UI, API, and WebDAV. Includes an admin UI for bulk editing (atomic updates) and safe defaults. +- 🔐 **Granular Access Control (ACL):** + Per-folder permissions for **owners**, **view**, **view (own)**, **write**, **manage**, **share**, and extended granular capabilities. + Each grant controls specific actions across the UI, API, and WebDAV: -- 🔌 **WebDAV Support (ACL-aware):** Mount FileRise as a network drive **or use it headless from the CLI**. Standard WebDAV ops (upload / download / delete) work in Cyberduck, WinSCP, GNOME Files, Finder, etc., and you can script with `curl`. Listings require **read**; users with **read_own** only see their own files; writes require **write**. + - **Manage / Owner:** Full control — can grant/revoke ACLs, rename/create/delete folders, and share subfolders. Implies all other permissions on that folder and its subfolders. + - **View (All):** See all files within a folder. Required for sharing folders. + - **View (Own):** See only your own uploads (useful for drop zones or limited contributors). + - **Write:** General write access — allows editing, renaming, moving, copying, deleting, and extracting files. + - **Create:** Allows creating subfolders (now gated by *Manage* or explicit *Create*). + - **Upload:** Upload new files (can be given without full write). + - **Edit / Rename / Copy / Move / Delete / Extract:** Individually controllable granular file actions. + - **Share File / Share Folder:** Create share links; folder shares require full View (All). + - **Automatic Propagation:** Enabling **Manage** on a folder applies to all subfolders; deselecting subfolder permissions overrides inheritance in the UI. -- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc. + ACL enforcement is centralized and atomic across: + - **Admin Panel:** Interactive ACL editor with batch save and dynamic inheritance visualization. + - **API Endpoints:** All file/folder operations validate server-side. + - **WebDAV:** Uses the same ACL engine — View / Own determine listings, granular permissions control upload/edit/delete/create. -- 📝 **Built-in Editor & Preview:** View images, videos, audio, and PDFs inline with a preview modal. Edit text/code files in your browser with a CodeMirror-based editor with syntax highlighting and line numbers. +- 🔌 **WebDAV (ACL-Aware):** Mount FileRise as a drive (Cyberduck, WinSCP, Finder, etc.) or access via `curl`. + - Listings require **View** or **View (Own)**. + - Uploads require **Upload**. + - Overwrites require **Edit**. + - Deletes require **Delete**. + - Creating folders requires **Create** or **Manage**. + - All ACLs and ownership rules are enforced exactly as in the web UI. -- 🏷️ **Tags & Search:** Categorize your files with color-coded tags and locate them instantly using indexed real-time search. **Advanced Search** adds fuzzy matching across file names, tags, uploader fields, and within text file contents. +- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) with interactive HTML docs (`api.html`) via Redoc. -- 🔒 **Auth & SSO:** Username/password login, optional TOTP 2FA, and OIDC (Google/Authentik/Keycloak). Per-user flags like **readOnly**/**disableUpload** still supported, but folder access is governed by the ACL above. +- 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers. -- 🗑️ **Trash & Recovery:** Deleted items go to Trash first; **admins** can restore or empty. Old trash entries auto-purge (default 3 days). +- 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents. -- 🎨 **Responsive UI (Dark/Light Mode):** Mobile-friendly layout with theme toggle. The interface remembers your preferences (layout, items per page, last visited folder, etc.). +- 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak). -- 🌐 **Internationalization & Localization:** Switch languages via the UI (English, Spanish, French, German). Contributions welcome. +- 🗑️ **Trash & Recovery:** Deleted items move to Trash for recovery (default 3-day retention). Admins can restore or purge globally. -- ⚙️ **Lightweight & Self-Contained:** Runs on PHP **8.3+** with no external database. Single-folder install or Docker image. Low footprint; scales to thousands of files with pagination and sorting. +- 🎨 **Responsive UI (Dark/Light Mode):** Modern, mobile-friendly design with persistent preferences (theme, layout, last folder, etc.). + +- 🌐 **Internationalization:** English, Spanish, French, and German available. Community translations welcome. + +- ⚙️ **Lightweight & Self-Contained:** Runs on PHP 8.3+, no external DB required. Single-folder or Docker deployment with minimal footprint, optimized for Unraid and self-hosting. (For full features and changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [CHANGELOG](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or [Releases](https://github.com/error311/FileRise/releases).) diff --git a/config/config.php b/config/config.php index 725788e..e0fa1b6 100644 --- a/config/config.php +++ b/config/config.php @@ -40,6 +40,7 @@ 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'); +define('ACL_INHERIT_ON_CREATE', true); // Encryption helpers function encryptData($data, $encryptionKey) diff --git a/public/api/admin/acl/getGrants.php b/public/api/admin/acl/getGrants.php index 1dee42c..6898a13 100644 --- a/public/api/admin/acl/getGrants.php +++ b/public/api/admin/acl/getGrants.php @@ -1,28 +1,5 @@ 'Unauthorized']); exit; } @@ -55,7 +31,7 @@ try { } catch (Throwable $e) { /* ignore */ } if (empty($folders)) { - $aclPath = META_DIR . 'folder_acl.json'; + $aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json'; if (is_file($aclPath)) { $data = json_decode((string)@file_get_contents($aclPath), true); if (is_array($data['folders'] ?? null)) { @@ -74,29 +50,36 @@ $has = function(array $arr, string $u): bool { $out = []; foreach ($folderList as $f) { - $rec = ACL::explicit($f); // owners, read, write, share, read_own + $rec = ACL::explicitAll($f); // legacy + granular - $isOwner = $has($rec['owners'], $user); - $canUpload = $isOwner || $has($rec['write'], $user); - - // IMPORTANT: full view only if owner or explicit read + $isOwner = $has($rec['owners'], $user); $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); + $canShare = $isOwner || $has($rec['share'], $user); + $canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user); - // Share only if owner or explicit share - $canShare = $isOwner || $has($rec['share'], $user); - - if ($canViewAll || $canViewOwn || $canUpload || $isOwner || $canShare) { + if ($canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner + || $has($rec['create'],$user) || $has($rec['edit'],$user) || $has($rec['rename'],$user) + || $has($rec['copy'],$user) || $has($rec['move'],$user) || $has($rec['delete'],$user) + || $has($rec['extract'],$user) || $has($rec['share_file'],$user) || $has($rec['share_folder'],$user)) { $out[$f] = [ - 'view' => $canViewAll, - 'viewOwn' => $canViewOwn, - 'upload' => $canUpload, - 'manage' => $isOwner, - 'share' => $canShare, + 'view' => $canViewAll, + 'viewOwn' => $canViewOwn, + 'write' => $has($rec['write'], $user) || $isOwner, + 'manage' => $isOwner, + 'share' => $canShare, // legacy + 'create' => $isOwner || $has($rec['create'], $user), + 'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'],$user), + 'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'],$user), + 'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'],$user), + 'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'],$user), + 'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'],$user), + 'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'],$user), + 'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'],$user), + 'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'],$user), + 'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'],$user), ]; } } -echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES); \ No newline at end of file +echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES); diff --git a/public/api/admin/acl/saveGrants.php b/public/api/admin/acl/saveGrants.php index 126cc89..761c5aa 100644 --- a/public/api/admin/acl/saveGrants.php +++ b/public/api/admin/acl/saveGrants.php @@ -1,27 +1,5 @@ $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; + $out[$folder] = normalize_caps($caps); } return $out; } @@ -124,4 +118,4 @@ if (isset($in['changes']) && is_array($in['changes'])) { // ---- Fallback -------------------------------------------------------------- http_response_code(400); -echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']); \ No newline at end of file +echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']); diff --git a/public/api/folder/capabilities.php b/public/api/folder/capabilities.php index 88e4da5..89cb333 100644 --- a/public/api/folder/capabilities.php +++ b/public/api/folder/capabilities.php @@ -51,7 +51,7 @@ * ) */ - +declare(strict_types=1); if (session_status() !== PHP_SESSION_ACTIVE) session_start(); require_once __DIR__ . '/../../../config/config.php'; @@ -88,30 +88,50 @@ function loadPermsFor(string $u): array { 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; +function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool { + $f = ACL::normalizeFolder($folder); + // direct owner + if (ACL::isOwner($user, $perms, $f)) return true; + // ancestor owner + while ($f !== '' && strcasecmp($f, 'root') !== 0) { + $pos = strrpos($f, '/'); + if ($pos === false) break; + $f = substr($f, 0, $pos); + if ($f === '' || strcasecmp($f, 'root') === 0) break; + if (ACL::isOwner($user, $perms, $f)) return true; } return false; } +/** + * folder-only scope: + * - Admins: always in scope + * - Non folder-only accounts: always in scope + * - Folder-only accounts: in scope iff: + * - folder == username OR subpath of username, OR + * - user is owner of this folder (or any ancestor) + */ 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); + //$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']); + //if (!$folderOnly) return true; + + $f = ACL::normalizeFolder($folder); + if ($f === 'root' || $f === '') { + // folder-only users cannot act on root unless they own a subfolder (handled below) + return isOwnerOrAncestorOwner($u, $perms, $f); + } + + if ($f === $u || str_starts_with($f, $u . '/')) return true; + + // Treat ownership as in-scope + return isOwnerOrAncestorOwner($u, $perms, $f); } // --- inputs --- $folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root'; -// validate folder path: allow "root" or nested segments matching REGEX_FOLDER_NAME + +// validate folder path if ($folder !== 'root') { $parts = array_filter(explode('/', trim($folder, "/\\ "))); if (empty($parts)) { @@ -129,44 +149,90 @@ if ($folder !== 'root') { $folder = implode('/', $parts); } -$perms = loadPermsFor($username); -$isAdmin = isAdminUser($username, $perms); +// --- user + flags --- +$perms = loadPermsFor($username); +$isAdmin = ACL::isAdmin($perms); +$readOnly = !empty($perms['readOnly']); +$disableUp = !empty($perms['disableUpload']); +$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin); -// 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); +// --- ACL base abilities --- +$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder); +$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder); +$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder); +$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder); -// scope + flags -$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin); -$readOnly = !empty($perms['readOnly']); -$disableUpload = !empty($perms['disableUpload']); +$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder); -$canUpload = $canWrite && !$readOnly && !$disableUpload && $inScope; -$canCreateFolder = $canWrite && !$readOnly && $inScope; -$canRename = $canWrite && !$readOnly && $inScope; -$canDelete = $canWrite && !$readOnly && $inScope; -$canMoveIn = $canWrite && !$readOnly && $inScope; +// granular base +$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder); +$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder); +$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder); +$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder); +$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder); +$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder); +$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder); +$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder); +$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder); +$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder); -// (optional) owner info if you need it client-side -$owner = FolderModel::getOwnerFor($folder); +// --- Apply scope + flags to effective UI actions --- +$canView = $canViewBase && $inScope; // keep scope for folder-only +$canUpload = $gUploadBase && !$readOnly && !$disableUpload && $inScope; +$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder** +$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder** +$canDelete = $gDeleteBase && !$readOnly && $inScope; +// Destination can receive items if user can create/write (or manage) here +$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope; +// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop) +$canMoveIn = $canReceive; +$canEdit = $gEditBase && !$readOnly && $inScope; +$canCopy = $gCopyBase && !$readOnly && $inScope; +$canExtract = $gExtractBase && !$readOnly && $inScope; + +// Sharing respects scope; optionally also gate on readOnly +$canShare = $canShareBase && $inScope; // legacy umbrella +$canShareFileEff = $gShareFile && $inScope; +$canShareFoldEff = $gShareFolder && $inScope; + +// never allow destructive ops on root +$isRoot = ($folder === 'root'); +if ($isRoot) { + $canRename = false; + $canDelete = false; + $canShareFoldEff = false; +} + +$owner = null; +try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {} -// output echo json_encode([ - 'user' => $username, - 'folder' => $folder, - 'isAdmin' => $isAdmin, - 'flags' => [ - 'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']), + 'user' => $username, + 'folder' => $folder, + 'isAdmin' => $isAdmin, + 'flags' => [ + //'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']), 'readOnly' => $readOnly, - 'disableUpload' => $disableUpload, + 'disableUpload' => $disableUp, ], - 'owner' => $owner, - 'canView' => $canRead, - 'canUpload' => $canUpload, - 'canCreate' => $canCreateFolder, - 'canRename' => $canRename, - 'canDelete' => $canDelete, - 'canMoveIn' => $canMoveIn, - 'canShare' => $canShare, + 'owner' => $owner, + + // viewing + 'canView' => $canView, + 'canViewOwn' => $canViewOwn, + + // write-ish + 'canUpload' => $canUpload, + 'canCreate' => $canCreate, + 'canRename' => $canRename, + 'canDelete' => $canDelete, + 'canMoveIn' => $canMoveIn, + 'canEdit' => $canEdit, + 'canCopy' => $canCopy, + 'canExtract' => $canExtract, + + // sharing + 'canShare' => $canShare, // legacy + 'canShareFile' => $canShareFileEff, + 'canShareFolder' => $canShareFoldEff, ], 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 fe29c02..f6d6547 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -4,10 +4,61 @@ import { loadAdminConfigFunc } from './auth.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { sendRequest } from './networkUtils.js'; -const version = "v1.5.3"; +const version = "v1.6.0"; const adminTitle = `${t("admin_panel")} ${version}`; -// Translate with fallback: if t(key) just echos the key, use a readable string. + +/* === BEGIN: Folder Access helpers (merged + improved) === */ +function qs(scope, sel){ return (scope||document).querySelector(sel); } +function qsa(scope, sel){ return Array.from((scope||document).querySelectorAll(sel)); } + +function enforceShareFolderRule(row) { + const manage = qs(row, 'input[data-cap="manage"]'); + const viewAll = qs(row, 'input[data-cap="view"]'); + const shareFolder = qs(row, 'input[data-cap="shareFolder"]'); + if (!shareFolder) return; + const ok = !!(manage && manage.checked) && !!(viewAll && viewAll.checked); + if (!ok) { + shareFolder.checked = false; + shareFolder.disabled = true; + shareFolder.setAttribute('data-disabled-reason', 'Requires Manage + View (all)'); + } else { + shareFolder.disabled = false; + shareFolder.removeAttribute('data-disabled-reason'); + } +} + +function onShareFolderToggle(row, checked) { + const manage = qs(row, 'input[data-cap="manage"]'); + const viewAll = qs(row, 'input[data-cap="view"]'); + if (checked) { + if (manage && !manage.checked) manage.checked = true; + if (viewAll && !viewAll.checked) viewAll.checked = true; + } + enforceShareFolderRule(row); +} + +function onShareFileToggle(row, checked) { + if (!checked) return; + const viewAll = qs(row, 'input[data-cap="view"]'); + const viewOwn = qs(row, 'input[data-cap="viewOwn"]'); + const hasView = !!(viewAll && viewAll.checked); + const hasOwn = !!(viewOwn && viewOwn.checked); + if (!hasView && !hasOwn && viewOwn) { + viewOwn.checked = true; + } +} + +function onWriteToggle(row, checked) { + const caps = ["create","upload","edit","rename","copy","move","delete","extract"]; + caps.forEach(c => { + const box = qs(row, `input[data-cap="${c}"]`); + if (box) box.checked = checked; + }); +} +/* === END: Folder Access helpers (merged + improved) === */ + +// Translate with fallback const tf = (key, fallback) => { const v = t(key); return (v && v !== key) ? v : fallback; @@ -44,19 +95,16 @@ async function safeJson(res) { color: #000 !important; border: 1px solid #ccc !important; } - /* Small phones: 90% width */ @media (max-width: 900px) { #adminPanelModal .modal-content { width: 90% !important; max-width: none !important; } } - /* Dark-mode fixes */ 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; @@ -67,10 +115,8 @@ async function safeJson(res) { 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%; @@ -80,37 +126,31 @@ async function safeJson(res) { #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; } - - /* 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 */ + --col-perm: 84px; + --col-folder-min: 340px; max-height: 320px; overflow: auto; border: 1px solid #ccc; border-radius: 6px; - padding: 0; /* no inner padding to keep grid aligned */ + padding: 0; } 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)); + grid-template-columns: minmax(var(--col-folder-min), 1fr) repeat(14, 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; @@ -121,24 +161,36 @@ async function safeJson(res) { } 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 */ + min-width: 0; } .muted { opacity:.65; font-size:.9em; } - /* Tighter on small screens */ + /* Inheritance visuals */ + .inherited-row { + opacity: 0.8; + background: rgba(32, 132, 255, 0.06); + } + .inherited-tag { + font-size: 11px; + padding: 2px 6px; + border-radius: 10px; + background: rgba(32,132,255,0.12); + color: #2064ff; + margin-left: 6px; + } + body.dark-mode .inherited-row { background: rgba(32,132,255,0.12); } + body.dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; } + @media (max-width: 900px) { .folder-access-list { --col-perm: 72px; --col-folder-min: 240px; } } @@ -149,34 +201,37 @@ async function safeJson(res) { let originalAdminConfig = {}; function captureInitialAdminConfig() { + const ht = document.getElementById("headerTitle"); originalAdminConfig = { - headerTitle: document.getElementById("headerTitle").value.trim(), - oidcProviderUrl: document.getElementById("oidcProviderUrl").value.trim(), - oidcClientId: document.getElementById("oidcClientId").value.trim(), - oidcClientSecret: document.getElementById("oidcClientSecret").value.trim(), - oidcRedirectUri: document.getElementById("oidcRedirectUri").value.trim(), - disableFormLogin: document.getElementById("disableFormLogin").checked, - disableBasicAuth: document.getElementById("disableBasicAuth").checked, - disableOIDCLogin: document.getElementById("disableOIDCLogin").checked, - enableWebDAV: document.getElementById("enableWebDAV").checked, - sharedMaxUploadSize: document.getElementById("sharedMaxUploadSize").value.trim(), - globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim() + headerTitle: ht ? ht.value.trim() : "", + oidcProviderUrl: (document.getElementById("oidcProviderUrl")?.value || "").trim(), + oidcClientId: (document.getElementById("oidcClientId")?.value || "").trim(), + oidcClientSecret: (document.getElementById("oidcClientSecret")?.value || "").trim(), + oidcRedirectUri: (document.getElementById("oidcRedirectUri")?.value || "").trim(), + disableFormLogin: !!document.getElementById("disableFormLogin")?.checked, + disableBasicAuth: !!document.getElementById("disableBasicAuth")?.checked, + disableOIDCLogin: !!document.getElementById("disableOIDCLogin")?.checked, + enableWebDAV: !!document.getElementById("enableWebDAV")?.checked, + sharedMaxUploadSize: (document.getElementById("sharedMaxUploadSize")?.value || "").trim(), + globalOtpauthUrl: (document.getElementById("globalOtpauthUrl")?.value || "").trim() }; } function hasUnsavedChanges() { const o = originalAdminConfig; + const getVal = id => (document.getElementById(id)?.value || "").trim(); + const getChk = id => !!document.getElementById(id)?.checked; return ( - document.getElementById("headerTitle").value.trim() !== o.headerTitle || - document.getElementById("oidcProviderUrl").value.trim() !== o.oidcProviderUrl || - document.getElementById("oidcClientId").value.trim() !== o.oidcClientId || - document.getElementById("oidcClientSecret").value.trim() !== o.oidcClientSecret || - document.getElementById("oidcRedirectUri").value.trim() !== o.oidcRedirectUri || - document.getElementById("disableFormLogin").checked !== o.disableFormLogin || - document.getElementById("disableBasicAuth").checked !== o.disableBasicAuth || - document.getElementById("disableOIDCLogin").checked !== o.disableOIDCLogin || - document.getElementById("enableWebDAV").checked !== o.enableWebDAV || - document.getElementById("sharedMaxUploadSize").value.trim() !== o.sharedMaxUploadSize || - document.getElementById("globalOtpauthUrl").value.trim() !== o.globalOtpauthUrl + getVal("headerTitle") !== o.headerTitle || + getVal("oidcProviderUrl") !== o.oidcProviderUrl || + getVal("oidcClientId") !== o.oidcClientId || + getVal("oidcClientSecret") !== o.oidcClientSecret || + getVal("oidcRedirectUri") !== o.oidcRedirectUri || + getChk("disableFormLogin") !== o.disableFormLogin || + getChk("disableBasicAuth") !== o.disableBasicAuth || + getChk("disableOIDCLogin") !== o.disableOIDCLogin || + getChk("enableWebDAV") !== o.enableWebDAV || + getVal("sharedMaxUploadSize") !== o.sharedMaxUploadSize || + getVal("globalOtpauthUrl") !== o.globalOtpauthUrl ); } @@ -186,6 +241,7 @@ function showCustomConfirmModal(message) { const msg = document.getElementById("confirmMessage"); const yes = document.getElementById("confirmYesBtn"); const no = document.getElementById("confirmNoBtn"); + if (!modal || !msg || !yes || !no) { resolve(true); return; } msg.textContent = message; modal.style.display = "block"; function clean() { @@ -203,6 +259,7 @@ function showCustomConfirmModal(message) { function toggleSection(id) { const hdr = document.getElementById(id + "Header"); const cnt = document.getElementById(id + "Content"); + if (!hdr || !cnt) return; const isCollapsedNow = hdr.classList.toggle("collapsed"); cnt.style.display = isCollapsedNow ? "none" : "block"; if (!isCollapsedNow && id === "shareLinks") { @@ -212,6 +269,7 @@ function toggleSection(id) { function loadShareLinksSection() { const container = document.getElementById("shareLinksContent"); + if (!container) return; container.textContent = t("loading") + "..."; function fetchMeta(fileName) { @@ -306,7 +364,8 @@ export function openAdminPanel() { .then(r => r.json()) .then(config => { if (config.header_title) { - document.querySelector(".header-title h1").textContent = config.header_title; + const h = document.querySelector(".header-title h1"); + if (h) h.textContent = config.header_title; window.headerTitle = config.header_title; } if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc); @@ -340,14 +399,14 @@ export function openAdminPanel() {