diff --git a/public/js/adminFolderAccess.js b/public/js/adminFolderAccess.js index b25fd44..a2c60b4 100644 --- a/public/js/adminFolderAccess.js +++ b/public/js/adminFolderAccess.js @@ -202,12 +202,18 @@ function buildFullGrantsForAllFolders(folders) { let __allFoldersCache = new Map(); -async function getAllFolders(force = false, sourceId = "") { - const key = sourceId || getFolderAccessSourceId() || ''; - if (!force && __allFoldersCache.has(key)) return __allFoldersCache.get(key).slice(); - - const url = '/api/folder/getFolderList.php?counts=0&ts=' + Date.now() - + (key ? `&sourceId=${encodeURIComponent(key)}` : ''); +async function getAllFolders(force = false, sourceId = "", parent = "root") { + // Load a single level (shallow: folder=&counts=0) instead of the full recursive tree. + // The full list walks every folder and renders one row per folder, which freezes the editor on + // large libraries. The backend already supports shallow listing (getFolderListLocalShallow); it + // just was never requested here. parent='root' = top level; drill into a subfolder by passing parent=. + const sid = sourceId || getFolderAccessSourceId() || ''; + const p = parent || 'root'; + const cacheKey = sid + '::' + p; + if (!force && __allFoldersCache.has(cacheKey)) return __allFoldersCache.get(cacheKey).slice(); + + const url = '/api/folder/getFolderList.php?counts=0&folder=' + encodeURIComponent(p) + '&ts=' + Date.now() + + (sid ? `&sourceId=${encodeURIComponent(sid)}` : ''); const res = await fetch(url, { credentials: 'include', cache: 'no-store', @@ -223,7 +229,7 @@ async function getAllFolders(force = false, sourceId = "") { .filter(f => f && !hidden.has(f.toLowerCase())) .sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b))); - __allFoldersCache.set(key, cleaned); + __allFoldersCache.set(cacheKey, cleaned); return cleaned.slice(); } @@ -239,7 +245,7 @@ async function getUserGrants(username, sourceId = "") { return (grants && typeof grants === 'object' && !Array.isArray(grants)) ? grants : {}; } -function renderFolderGrantsUI(principal, container, folders, grants) { +function renderFolderGrantsUI(principal, container, folders, grants, parent = 'root', sourceId = '') { if (!Array.isArray(folders) || !container) return; const grantsMap = (grants && typeof grants === 'object' && !Array.isArray(grants)) ? grants : {}; @@ -301,6 +307,45 @@ function renderFolderGrantsUI(principal, container, folders, grants) { container.innerHTML = ''; container.appendChild(toolbar); + + // Drill-in into subfolders: load one level at a time (shallow). getAllFolders takes a parent arg. + const __sid = sourceId || (typeof getFolderAccessSourceId === 'function' ? getFolderAccessSourceId() : '') || ''; + const __parent = parent || 'root'; + async function navigateFolderAccess(newParent) { + // Collect in-progress edits into the fallback map before navigating so they survive the + // re-render (save is batched via collectGrantsFrom). + const acc = collectGrantsFrom(container, container.__grantsFallback || {}); + let f = []; + try { f = await getAllFolders(true, __sid, newParent || 'root'); } catch (e) { f = []; } + renderFolderGrantsUI(principal, container, f, acc, newParent || 'root', __sid); + } + const crumb = document.createElement('div'); + crumb.className = 'folder-access-breadcrumb'; + crumb.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;align-items:center;margin:4px 0 8px;font-size:13px;'; + (() => { + const mkLink = (label, target) => { + const a = document.createElement('a'); + a.href = '#'; a.textContent = label; + a.style.cssText = 'cursor:pointer;text-decoration:underline;'; + a.addEventListener('click', (e) => { e.preventDefault(); navigateFolderAccess(target); }); + return a; + }; + crumb.appendChild(mkLink(tf('root_folder', 'root') + ' /', 'root')); + const parts = (__parent === 'root') ? [] : __parent.split('/').filter(Boolean); + let accPath = ''; + parts.forEach((seg, i) => { + accPath = accPath ? (accPath + '/' + seg) : seg; + const sep = document.createElement('span'); sep.textContent = '›'; sep.style.opacity = '0.5'; + crumb.appendChild(sep); + if (i === parts.length - 1) { + const cur = document.createElement('strong'); cur.textContent = seg; + crumb.appendChild(cur); + } else { + crumb.appendChild(mkLink(seg, accPath)); + } + }); + })(); + container.appendChild(crumb); container.appendChild(list); const rowHtml = (folder, idx) => { @@ -330,7 +375,7 @@ function renderFolderGrantsUI(principal, container, folders, grants) { g.shareFolder = g.shareFolder !== false; } - const name = folder === "root" ? `${t("root_folder")} /` : folder; + const name = folder === "root" ? `${t("root_folder")} /` : folder.split('/').pop(); const shareFolderDisabled = isAdmin ? true : undefined; const toggle = (cap, label, checked, disabled, title = "") => ` @@ -356,6 +401,7 @@ function renderFolderGrantsUI(principal, container, folders, grants) {
folder ${name} + ${(folder !== 'root' && folder !== __parent) ? `` : ''} @@ -546,6 +592,13 @@ function renderFolderGrantsUI(principal, container, folders, grants) { } function wireRow(row) { + const drillBtn = row.querySelector('.fa-drill-btn'); + if (drillBtn) { + drillBtn.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + navigateFolderAccess(drillBtn.dataset.drill); + }); + } const isAdminRow = row.dataset.admin === '1'; const cbView = row.querySelector('input[data-cap="view"]'); const cbViewOwn = row.querySelector('input[data-cap="viewOwn"]'); diff --git a/src/FileRise/Http/Controllers/AclAdminController.php b/src/FileRise/Http/Controllers/AclAdminController.php index 72956f3..de00d4d 100644 --- a/src/FileRise/Http/Controllers/AclAdminController.php +++ b/src/FileRise/Http/Controllers/AclAdminController.php @@ -20,32 +20,21 @@ public function getUserGrants(string $user): array throw new InvalidArgumentException('Invalid user'); } + // Grants can only exist on folders that have an ACL entry, so read those directly from + // folder_acl.json instead of walking the ENTIRE folder tree. FolderModel::getFolderList() + // with no arguments is a full recursive scan, and getGrants runs once per user, so the admin + // user/folder matrix took minutes to load on large libraries. Folders without an ACL entry + // carry no grant anyway. $folders = []; - try { - $rows = FolderModel::getFolderList(); - if (is_array($rows)) { - foreach ($rows as $r) { - $f = is_array($r) ? ($r['folder'] ?? '') : (string)$r; - if ($f !== '') { - $folders[$f] = true; - } - } - } - } catch (\Throwable $e) { - // ignore, fall back to ACL file - } - - if (empty($folders)) { - $metaRoot = class_exists('SourceContext') - ? SourceContext::metaRoot() - : rtrim((string)META_DIR, "/\\") . DIRECTORY_SEPARATOR; - $aclPath = rtrim($metaRoot, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json'; - if (is_file($aclPath)) { - $data = json_decode((string)@file_get_contents($aclPath), true); - if (is_array($data['folders'] ?? null)) { - foreach ($data['folders'] as $name => $_) { - $folders[$name] = true; - } + $metaRoot = class_exists('SourceContext') + ? SourceContext::metaRoot() + : rtrim((string)META_DIR, "/\\") . DIRECTORY_SEPARATOR; + $aclPath = rtrim($metaRoot, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json'; + if (is_file($aclPath)) { + $data = json_decode((string)@file_get_contents($aclPath), true); + if (is_array($data['folders'] ?? null)) { + foreach ($data['folders'] as $name => $_) { + $folders[$name] = true; } } } diff --git a/src/FileRise/Http/Controllers/FolderController.php b/src/FileRise/Http/Controllers/FolderController.php index 1671592..15eeb52 100644 --- a/src/FileRise/Http/Controllers/FolderController.php +++ b/src/FileRise/Http/Controllers/FolderController.php @@ -1667,6 +1667,20 @@ public function getFolderList(): void // Optional "folder" filter (supports nested like "team/reports") $parent = $_GET['folder'] ?? null; + + // When no explicit folder= is given, default to a shallow (top-level) listing instead of a + // full recursive walk of the whole tree. FolderModel::getFolderList() with no parent walks + // every folder via getSubfolders(), which is O(n) over the entire library and freezes any + // caller that pulls the full folder list (admin folder-access editor, portals, folder + // pickers, datalists) on large trees. Directory navigation passes an explicit folder=, so it + // is unaffected. Callers that genuinely need the full recursive list can opt in with &deep=1. + $deepRaw = $_GET['deep'] ?? null; + $deep = ($deepRaw !== null && in_array(strtolower((string)$deepRaw), ['1', 'true', 'yes'], true)); + if ($parent === null && !$deep) { + $parent = 'root'; + $includeCounts = false; // the model's shallow path requires counts disabled + } + if ($parent !== null && $parent !== '' && strcasecmp($parent, 'root') !== 0) { $parts = array_filter(explode('/', trim($parent, "/\\ ")), fn($p) => $p !== ''); if (empty($parts)) {