From ee717af7507cc7b40ea54e73382f9731a0a8dc4d Mon Sep 17 00:00:00 2001 From: Ryan Date: Wed, 22 Oct 2025 21:36:04 -0400 Subject: [PATCH] feat(acl): granular per-folder permissions + stricter gates; WebDAV & UI aligned --- CHANGELOG.md | 17 + README.md | 61 ++- config/config.php | 1 + public/api/admin/acl/getGrants.php | 67 +-- public/api/admin/acl/saveGrants.php | 62 ++- public/api/folder/capabilities.php | 160 +++++-- public/js/adminPanel.js | 684 ++++++++++++++++----------- public/js/folderManager.js | 38 +- src/controllers/FileController.php | 484 ++++++++++++------- src/controllers/FolderController.php | 133 ++++-- src/controllers/UploadController.php | 2 +- src/lib/ACL.php | 294 ++++++++---- src/models/FolderModel.php | 95 ++-- src/webdav/FileRiseDirectory.php | 8 +- src/webdav/FileRiseFile.php | 51 +- 15 files changed, 1332 insertions(+), 825 deletions(-) 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() {

${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 => ` @@ -363,20 +422,15 @@ export function openAdminPanel() { `; document.body.appendChild(mdl); - // Bind close & cancel - document.getElementById("closeAdminPanel") - .addEventListener("click", closeAdminPanel); - document.getElementById("cancelAdminSettings") - .addEventListener("click", closeAdminPanel); + document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel); + document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel); - // Section toggles ["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks"] .forEach(id => { document.getElementById(id + "Header") .addEventListener("click", () => toggleSection(id)); }); - // — User Mgmt — document.getElementById("userManagementContent").innerHTML = ` @@ -384,11 +438,10 @@ export function openAdminPanel() { `; - document.getElementById("adminOpenAddUser") .addEventListener("click", () => { toggleVisibility("addUserModal", true); - document.getElementById("newUsername").focus(); + document.getElementById("newUsername")?.focus(); }); document.getElementById("adminOpenRemoveUser") .addEventListener("click", () => { @@ -398,15 +451,13 @@ export function openAdminPanel() { document.getElementById("adminOpenUserPermissions") .addEventListener("click", openUserPermissionsModal); - // — Header Settings — document.getElementById("headerSettingsContent").innerHTML = `
- +
`; - // — Login Options — document.getElementById("loginOptionsContent").innerHTML = `
@@ -421,12 +472,10 @@ export function openAdminPanel() { `; - // — WebDAV — document.getElementById("webdavContent").innerHTML = `
`; - // — Upload — document.getElementById("uploadContent").innerHTML = `
@@ -435,22 +484,19 @@ export function openAdminPanel() {
`; - // — OIDC & TOTP — document.getElementById("oidcContent").innerHTML = `
Note: OIDC credentials (Client ID/Secret) will show blank here after saving, but remain unchanged until you explicitly edit and save them.
-
-
-
-
-
+
+
+
+
+
`; - // — Share Links — document.getElementById("shareLinksContent").textContent = t("loading") + "…"; - // — Save handler & constraints — document.getElementById("saveAdminSettings") .addEventListener("click", handleSave); ["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"].forEach(id => { @@ -471,28 +517,16 @@ 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(); - } + if (flagsBtn) { e.preventDefault(); openUserFlagsModal(); } const folderBtn = e.target.closest("#adminOpenUserPermissions"); - if (folderBtn) { - e.preventDefault(); - openUserPermissionsModal(); - } + 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; document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; @@ -503,7 +537,6 @@ export function openAdminPanel() { captureInitialAdminConfig(); } else { - // modal already exists → just refresh values & re-show mdl.style.display = "flex"; document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; @@ -512,11 +545,11 @@ export function openAdminPanel() { document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User"; document.getElementById("enableWebDAV").checked = config.enableWebDAV === true; document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || ""; - document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig.providerUrl; - document.getElementById("oidcClientId").value = window.currentOIDCConfig.clientId; - document.getElementById("oidcClientSecret").value = window.currentOIDCConfig.clientSecret; - document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig.redirectUri; - document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig.globalOtpauthUrl || ''; + document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || ""; + document.getElementById("oidcClientId").value = window.currentOIDCConfig?.clientId || ""; + document.getElementById("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || ""; + document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || ""; + document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || ''; captureInitialAdminConfig(); } }) @@ -524,21 +557,21 @@ export function openAdminPanel() { } function handleSave() { - const dFL = document.getElementById("disableFormLogin").checked; - const dBA = document.getElementById("disableBasicAuth").checked; - const dOIDC = document.getElementById("disableOIDCLogin").checked; - const aBypass = document.getElementById("authBypass").checked; - const aHeader = document.getElementById("authHeaderName").value.trim() || "X-Remote-User"; - const eWD = document.getElementById("enableWebDAV").checked; - const sMax = parseInt(document.getElementById("sharedMaxUploadSize").value, 10) || 0; - const nHT = document.getElementById("headerTitle").value.trim(); + const dFL = !!document.getElementById("disableFormLogin")?.checked; + const dBA = !!document.getElementById("disableBasicAuth")?.checked; + const dOIDC = !!document.getElementById("disableOIDCLogin")?.checked; + const aBypass = !!document.getElementById("authBypass")?.checked; + const aHeader = (document.getElementById("authHeaderName")?.value || "X-Remote-User").trim(); + const eWD = !!document.getElementById("enableWebDAV")?.checked; + const sMax = parseInt(document.getElementById("sharedMaxUploadSize")?.value || "0", 10) || 0; + const nHT = (document.getElementById("headerTitle")?.value || "").trim(); const nOIDC = { - providerUrl: document.getElementById("oidcProviderUrl").value.trim(), - clientId: document.getElementById("oidcClientId").value.trim(), - clientSecret: document.getElementById("oidcClientSecret").value.trim(), - redirectUri: document.getElementById("oidcRedirectUri").value.trim() + providerUrl: (document.getElementById("oidcProviderUrl")?.value || "").trim(), + clientId: (document.getElementById("oidcClientId")?.value || "").trim(), + clientSecret: (document.getElementById("oidcClientSecret")?.value || "").trim(), + redirectUri: (document.getElementById("oidcRedirectUri")?.value || "").trim() }; - const gURL = document.getElementById("globalOtpauthUrl").value.trim(); + const gURL = (document.getElementById("globalOtpauthUrl")?.value || "").trim(); if ([dFL, dBA, dOIDC].filter(x => x).length === 3) { showToast(t("at_least_one_login_method")); @@ -576,7 +609,8 @@ export async function closeAdminPanel() { const ok = await showCustomConfirmModal(t("unsaved_changes_confirm")); if (!ok) return; } - document.getElementById("adminPanelModal").style.display = "none"; + const m = document.getElementById("adminPanelModal"); + if (m) m.style.display = "none"; } /* =========================== @@ -591,7 +625,6 @@ async function getAllFolders() { 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())) @@ -605,7 +638,6 @@ async function getUserGrants(username) { credentials: 'include' }); const data = await safeJson(res).catch(() => ({})); - // expected: { grants: { "folder/name": {view,upload,manage,share}, ... } } return (data && data.grants) ? data.grants : {}; } @@ -615,131 +647,226 @@ function renderFolderGrantsUI(username, container, folders, grants) { // toolbar const toolbar = document.createElement('div'); toolbar.className = 'folder-access-toolbar'; - // Toolbar (bulk toggles with descriptions) toolbar.innerHTML = ` - - - - - - - (${tf('applies_to_filtered', 'applies to filtered list')}) -`; + + + + + + + (${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); - // Header (compact labels, descriptive tooltips so the column width stays the same) const headerHtml = `
${tf('folder', 'Folder')}
-
- ${tf('view_all', 'View (all)')} -
-
- ${tf('view_own', 'View (own)')} -
-
- ${tf('write', 'Write')} -
-
- ${tf('manage', 'Manage')} -
-
- ${tf('share', 'Share')} -
-
-`; +
${tf('view_all', 'View (all)')}
+
${tf('view_own', 'View (own)')}
+
${tf('write_full', 'Write')}
+
${tf('manage', 'Manage')}
+
${tf('create', 'Create')}
+
${tf('upload', 'Upload')}
+
${tf('edit', 'Edit')}
+
${tf('rename', 'Rename')}
+
${tf('copy', 'Copy')}
+
${tf('move', 'Move')}
+
${tf('delete', 'Delete')}
+
${tf('extract', 'Extract ZIP')}
+
${tf('share_file', 'Share File')}
+
${tf('share_folder', 'Share Folder')}
+ `; function rowHtml(folder) { const g = grants[folder] || {}; const name = folder === 'root' ? '(Root)' : folder; + const writeMetaChecked = !!(g.create || g.upload || g.edit || g.rename || g.copy || g.move || g.delete || g.extract); + const shareFolderDisabled = !g.view; return `
-
folder${name}
-
-
-
-
-
+
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"]'); + function setRowDisabled(row, disabled) { + qsa(row, 'input[type="checkbox"]').forEach(cb => { + cb.disabled = disabled || cb.hasAttribute('data-hard-disabled'); + }); + row.classList.toggle('inherited-row', !!disabled); + const tag = row.querySelector('.inherited-tag'); + if (tag) tag.style.display = disabled ? 'inline-block' : 'none'; + } - // Manage ⇒ full view + upload + share - if (cbManage.checked) { - cbView.checked = true; - cbUpload.checked = true; - cbShare.checked = true; + function refreshInheritance() { + const rows = qsa(list, '.folder-access-row').sort((a,b)=> (a.dataset.folder||'').length - (b.dataset.folder||'').length); + const managedPrefixes = new Set(); + rows.forEach(row => { + const folder = row.dataset.folder || ""; + const manage = qs(row, 'input[data-cap="manage"]'); + if (manage && manage.checked) managedPrefixes.add(folder); + let inheritedFrom = null; + for (const p of managedPrefixes) { + if (p && folder !== p && folder.startsWith(p + '/')) { inheritedFrom = p; break; } + } + if (inheritedFrom) { + const v = qs(row,'input[data-cap="view"]'); + const w = qs(row,'input[data-cap="write"]'); + const vo= qs(row,'input[data-cap="viewOwn"]'); + if (v) v.checked = true; + if (w) w.checked = true; + if (vo) { vo.checked = false; vo.disabled = true; } + ['create','upload','edit','rename','copy','move','delete','extract','shareFile','shareFolder'] + .forEach(c => { const cb = qs(row, `input[data-cap="${c}"]`); if (cb) cb.checked = true; }); + setRowDisabled(row, true); + const tag = row.querySelector('.inherited-tag'); + if (tag) tag.textContent = `(${tf('inherited', 'inherited')} ${tf('from', 'from')} ${inheritedFrom})`; + } else { + setRowDisabled(row, false); + } + enforceShareFolderRule(row); + const cbView = qs(row,'input[data-cap="view"]'); + const cbViewOwn = qs(row,'input[data-cap="viewOwn"]'); + if (cbView && cbViewOwn) { + if (cbView.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'); + } + } + }); + } + + function setFromViewChange(row, which, checked) { + if (!checked && (which === 'view' || which === 'viewOwn')) { + qsa(row, 'input[type="checkbox"]').forEach(cb => cb.checked = false); } - - // 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'); + const cbView = qs(row,'input[data-cap="view"]'); + const cbVO = qs(row,'input[data-cap="viewOwn"]'); + if (cbView && cbVO) { + if (cbView.checked) { + cbVO.checked = false; + cbVO.disabled = true; + cbVO.title = tf('full_view_supersedes_own', 'Full view supersedes own-only'); + } else { + cbVO.disabled = false; + cbVO.removeAttribute('title'); + } } + enforceShareFolderRule(row); } function wireRow(row) { - const cbView = row.querySelector('input[data-cap="view"]'); + 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"]'); + const cbWrite = row.querySelector('input[data-cap="write"]'); + const cbManage = row.querySelector('input[data-cap="manage"]'); + const cbCreate = row.querySelector('input[data-cap="create"]'); + const cbUpload = row.querySelector('input[data-cap="upload"]'); + const cbEdit = row.querySelector('input[data-cap="edit"]'); + const cbRename = row.querySelector('input[data-cap="rename"]'); + const cbCopy = row.querySelector('input[data-cap="copy"]'); + const cbMove = row.querySelector('input[data-cap="move"]'); + const cbDelete = row.querySelector('input[data-cap="delete"]'); + const cbExtract = row.querySelector('input[data-cap="extract"]'); + const cbShareF = row.querySelector('input[data-cap="shareFile"]'); + const cbShareFo = row.querySelector('input[data-cap="shareFolder"]'); - cbUpload.addEventListener('change', () => applyDeps(row)); - cbShare.addEventListener('change', () => applyDeps(row)); - cbManage.addEventListener('change', () => applyDeps(row)); + const granular = [cbCreate, cbUpload, cbEdit, cbRename, cbCopy, cbMove, cbDelete, cbExtract]; - cbView.addEventListener('change', () => { - if (!cbView.checked) { cbManage.checked = false; cbShare.checked = false; } - applyDeps(row); - }); - cbViewOwn.addEventListener('change', () => applyDeps(row)); + const applyManage = () => { + if (cbManage && cbManage.checked) { + if (cbView) cbView.checked = true; + if (cbWrite) cbWrite.checked = true; + granular.forEach(cb => { if (cb) cb.checked = true; }); + if (cbShareF) cbShareF.checked = true; + if (cbShareFo && !cbShareFo.disabled) cbShareFo.checked = true; + } + }; - applyDeps(row); + const syncWriteFromGranular = () => { + if (!cbWrite) return; + cbWrite.checked = granular.some(cb => cb && cb.checked); + }; + const applyWrite = () => { + if (!cbWrite) return; + granular.forEach(cb => { if (cb) cb.checked = cbWrite.checked; }); + const any = granular.some(cb => cb && cb.checked); + if (any && cbView && !cbView.checked && cbViewOwn && !cbViewOwn.checked) cbViewOwn.checked = true; + }; + + const onShareFile = () => { + if (cbShareF && cbShareF.checked && cbView && !cbView.checked && cbViewOwn && !cbViewOwn.checked) { + cbViewOwn.checked = true; + } + }; + + const cascadeManage = (checked) => { + const base = row.dataset.folder || ""; + if (!base) return; + qsa(container, '.folder-access-row').forEach(r => { + const f = r.dataset.folder || ""; + if (!f || f === base) return; + if (!f.startsWith(base + '/')) return; + const m = r.querySelector('input[data-cap="manage"]'); + const v = r.querySelector('input[data-cap="view"]'); + const w = r.querySelector('input[data-cap="write"]'); + const vo = r.querySelector('input[data-cap="viewOwn"]'); + const boxes = [ + 'create','upload','edit','rename','copy','move','delete','extract','shareFile','shareFolder' + ].map(c => r.querySelector(`input[data-cap="${c}"]`)); + if (m) m.checked = checked; + if (v) v.checked = checked; + if (w) w.checked = checked; + if (vo) { vo.checked = false; vo.disabled = checked; } + boxes.forEach(b => { if (b) b.checked = checked; }); + enforceShareFolderRule(r); + }); + refreshInheritance(); + }; + + if (cbManage) cbManage.addEventListener('change', () => { applyManage(); onShareFile(); cascadeManage(cbManage.checked); }); + if (cbWrite) cbWrite.addEventListener('change', applyWrite); + granular.forEach(cb => { if (cb) cb.addEventListener('change', () => { syncWriteFromGranular(); }); }); + if (cbView) cbView.addEventListener('change', () => { setFromViewChange(row, 'view', cbView.checked); refreshInheritance(); }); + if (cbViewOwn) cbViewOwn.addEventListener('change', () => { setFromViewChange(row, 'viewOwn', cbViewOwn.checked); refreshInheritance(); }); + if (cbShareF) cbShareF.addEventListener('change', onShareFile); + if (cbShareFo) cbShareFo.addEventListener('change', () => onShareFolderToggle(row, cbShareFo.checked)); + + applyManage(); + enforceShareFolderRule(row); + syncWriteFromGranular(); } function render(filter = "") { @@ -750,16 +877,14 @@ function renderFolderGrantsUI(username, container, folders, grants) { .join(""); list.innerHTML = headerHtml + rowsHtml; - list.querySelectorAll('.folder-access-row').forEach(wireRow); + refreshInheritance(); } - // 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; @@ -774,50 +899,56 @@ function renderFolderGrantsUI(username, container, folders, grants) { 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; + if (which === 'manage') { + target.dispatchEvent(new Event('change')); + } else if (which === 'share') { + if (bulk.checked) { + const v = row.querySelector('input[data-cap="view"]'); + if (v) v.checked = true; + } + } else if (which === 'write') { + onWriteToggle(row, bulk.checked); + } else if (which === 'view' || which === 'viewOwn') { + setFromViewChange(row, which, bulk.checked); } - applyDeps(row); + enforceShareFolderRule(row); }); + refreshInheritance(); }); }); } -// Collect grants from a user's UI function collectGrantsFrom(container) { const out = {}; + const get = (row, sel) => { + const el = row.querySelector(sel); + return el ? !!el.checked : false; + }; container.querySelectorAll('.folder-access-row').forEach(row => { - const folder = row.dataset.folder; + const folder = row.dataset.folder || row.getAttribute('data-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 + view: get(row, 'input[data-cap="view"]'), + viewOwn: get(row, 'input[data-cap="viewOwn"]'), + manage: get(row, 'input[data-cap="manage"]'), + create: get(row, 'input[data-cap="create"]'), + upload: get(row, 'input[data-cap="upload"]'), + edit: get(row, 'input[data-cap="edit"]'), + rename: get(row, 'input[data-cap="rename"]'), + copy: get(row, 'input[data-cap="copy"]'), + move: get(row, 'input[data-cap="move"]'), + delete: get(row, 'input[data-cap="delete"]'), + extract: get(row, 'input[data-cap="extract"]'), + shareFile: get(row, 'input[data-cap="shareFile"]'), + shareFolder: get(row, 'input[data-cap="shareFolder"]') }; - if (g.view || g.viewOwn || g.upload || g.manage || g.share) out[folder] = g; + g.share = !!(g.shareFile || g.shareFolder); + 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"); @@ -826,7 +957,6 @@ export function openUserPermissionsModal() { background: ${isDarkMode ? "#2c2c2c" : "#fff"}; color: ${isDarkMode ? "#e0e0e0" : "#000"}; padding: 20px; - /* Wider, responsive */ width: clamp(980px, 92vw, 1280px); max-width: none; border-radius: 8px; @@ -853,7 +983,6 @@ export function openUserPermissionsModal() { ${tf("grant_folders_help", "Grant per-folder capabilities to each user. 'Write/Manage/Share' imply 'View'.")}
-
@@ -869,25 +998,22 @@ export function openUserPermissionsModal() { userPermissionsModal.style.display = "none"; }); 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"); - let saves = []; + const changes = []; rows.forEach(row => { - const username = row.getAttribute("data-username"); + const username = String(row.getAttribute("data-username") || "").trim(); + if (!username) return; const grantsBox = row.querySelector(".folder-grants-box"); - if (!username || !grantsBox) return; + if (!grantsBox || grantsBox.getAttribute('data-loaded') !== '1') return; const grants = collectGrantsFrom(grantsBox); - saves.push({ user: username, grants }); + changes.push({ user: username, grants }); }); - 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 }); - } + if (changes.length === 0) { showToast(tf("nothing_to_save", "Nothing to save")); return; } + await sendRequest("/api/admin/acl/saveGrants.php", "POST", + { changes }, + { "X-CSRF-Token": window.csrfToken || "" } + ); showToast(tf("user_permissions_updated_successfully", "User permissions updated successfully")); userPermissionsModal.style.display = "none"; } catch (err) { @@ -904,26 +1030,20 @@ export function openUserPermissionsModal() { async function fetchAllUsers() { const r = await fetch("/api/getUsers.php", { credentials: "include" }); - return await r.json(); // array of { username, role } + return await r.json(); } -// 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; + 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; @@ -933,7 +1053,7 @@ async function fetchAllUserFlags() { 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 + if (isAdmin) return ""; return ` ${u.username} @@ -992,7 +1112,6 @@ export async function openUserFlagsModal() { document.getElementById("cancelUserFlags").onclick = () => (modal.style.display = "none"); document.getElementById("saveUserFlags").onclick = saveUserFlags; } else { - // Re-apply theme if user toggled dark mode since last open modal.style.background = overlayBg; const content = modal.querySelector(".modal-content"); if (content) { @@ -1008,10 +1127,11 @@ export async function openUserFlagsModal() { async function loadUserFlagsList() { const body = document.getElementById("userFlagsBody"); + if (!body) return; body.textContent = `${t("loading")}…`; try { - const users = await fetchAllUsers(); // [{username, role}] - const flagsMap = await fetchAllUserFlags(); // { username: {…} } + const users = await fetchAllUsers(); + const flagsMap = await fetchAllUserFlags(); const rows = users.map(u => flagRow(u, flagsMap)).filter(Boolean).join(""); body.innerHTML = ` @@ -1035,7 +1155,7 @@ async function loadUserFlagsList() { async function saveUserFlags() { const body = document.getElementById("userFlagsBody"); - const rows = body.querySelectorAll("tbody tr[data-username]"); + const rows = body?.querySelectorAll("tbody tr[data-username]") || []; const permissions = []; rows.forEach(tr => { const username = tr.getAttribute("data-username"); @@ -1050,14 +1170,14 @@ async function saveUserFlags() { }); 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"; + const m = document.getElementById("userFlagsModal"); + if (m) m.style.display = "none"; } else { showToast(tf("error_updating_permissions", "Error updating permissions"), "error"); } @@ -1081,12 +1201,10 @@ async function loadUserPermissionsList() { return; } - // Preload folders once (admin should see all) const folders = await getAllFolders(); - listContainer.innerHTML = ""; // clear + listContainer.innerHTML = ""; users.forEach(user => { - // Skip admins if ((user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin") return; const row = document.createElement("div"); @@ -1095,27 +1213,15 @@ async function loadUserPermissionsList() { row.style.padding = "6px 0"; row.innerHTML = ` -