diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c1f46..731c86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## Changes 11/19/2025 (v1.9.12) + +release(v1.9.12): feat(pro-acl): add user groups and group-aware ACL + +- Add Pro user groups as a first-class ACL source: + - Load group grants from FR_PRO_BUNDLE_DIR/groups.json in ACL::hasGrant(). + - Treat group grants as additive only; they can never remove access. + +- Introduce AclAdminController: + - Move getGrants/saveGrants logic into a dedicated controller. + - Keep existing ACL normalization and business rules (shareFolder ⇒ view, shareFile ⇒ at least viewOwn). + - Refactor public/api/admin/acl/getGrants.php and saveGrants.php to use the controller. + +- Implement Pro user group storage and APIs: + - Add ProGroups store class under FR_PRO_BUNDLE_DIR (groups.json with {name,label,members,grants}). + - Add /api/pro/groups/list.php and /api/pro/groups/save.php, guarded by AdminController::requireAuth/requireAdmin/requireCsrf(). + - Keep groups and bundle code behind FR_PRO_ACTIVE/FR_PRO_BUNDLE_DIR checks. + +- Ship Pro-only endpoints from core instead of the bundle: + - Move public/api/pro/uploadBrandLogo.php into core and gate it on FR_PRO_ACTIVE. + - Remove start.sh logic that copied public/api/pro from the Pro bundle into the container image. + +- Extend admin UI for user groups: + - Turn “User groups” into a real Pro-only modal with add/delete groups, multi-select members, and member chips. + - Add “Edit folder access” for each group, reusing the existing folder grants grid. + - Overlay group grants when editing a user’s ACL: + - Show which caps are coming from groups, lock those checkboxes, and update tooltips. + - Show group membership badges in the user permissions list. + - Add a collapsed “Groups” section at the top of the permissions screen to preview group ACLs (read-only). + +- Misc: + - Bump PRO_LATEST_BUNDLE_VERSION hint in adminPanel.js to v1.0.1. + - Tweak modal border-radius styling to include the new userGroups and groupAcl modals. + +--- + ## Changes 11/18/2025 (v1.9.11) release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68) diff --git a/public/api/admin/acl/getGrants.php b/public/api/admin/acl/getGrants.php index 6898a13..82ae7ca 100644 --- a/public/api/admin/acl/getGrants.php +++ b/public/api/admin/acl/getGrants.php @@ -3,83 +3,26 @@ declare(strict_types=1); require_once __DIR__ . '/../../../../config/config.php'; -require_once PROJECT_ROOT . '/src/lib/ACL.php'; -require_once PROJECT_ROOT . '/src/models/FolderModel.php'; +require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php'; if (session_status() !== PHP_SESSION_ACTIVE) session_start(); header('Content-Type: application/json'); if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) { - http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; + http_response_code(401); + echo json_encode(['error' => 'Unauthorized']); + exit; } $user = trim((string)($_GET['user'] ?? '')); -if ($user === '' || !preg_match(REGEX_USER, $user)) { - http_response_code(400); echo json_encode(['error'=>'Invalid user']); exit; -} - -// Build the folder list (admin sees all) -$folders = []; try { - $rows = FolderModel::getFolderList(); - if (is_array($rows)) { - foreach ($rows as $r) { - $f = is_array($r) ? ($r['folder'] ?? '') : (string)$r; - if ($f !== '') $folders[$f] = true; - } - } -} catch (Throwable $e) { /* ignore */ } - -if (empty($folders)) { - $aclPath = 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)) { - foreach ($data['folders'] as $name => $_) $folders[$name] = true; - } - } -} - -$folderList = array_keys($folders); -if (!in_array('root', $folderList, true)) array_unshift($folderList, 'root'); - -$has = function(array $arr, string $u): bool { - foreach ($arr as $x) if (strcasecmp((string)$x, $u) === 0) return true; - return false; -}; - -$out = []; -foreach ($folderList as $f) { - $rec = ACL::explicitAll($f); // legacy + granular - - $isOwner = $has($rec['owners'], $user); - $canViewAll = $isOwner || $has($rec['read'], $user); - $canViewOwn = $has($rec['read_own'], $user); - $canShare = $isOwner || $has($rec['share'], $user); - $canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user); - - 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, - '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); + $ctrl = new AclAdminController(); + $grants = $ctrl->getUserGrants($user); + echo json_encode(['grants' => $grants], JSON_UNESCAPED_SLASHES); +} catch (InvalidArgumentException $e) { + http_response_code(400); + echo json_encode(['error' => $e->getMessage()]); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => 'Failed to load grants', 'detail' => $e->getMessage()]); +} \ No newline at end of file diff --git a/public/api/admin/acl/saveGrants.php b/public/api/admin/acl/saveGrants.php index 761c5aa..4d5a1a1 100644 --- a/public/api/admin/acl/saveGrants.php +++ b/public/api/admin/acl/saveGrants.php @@ -3,12 +3,11 @@ declare(strict_types=1); require_once __DIR__ . '/../../../../config/config.php'; -require_once PROJECT_ROOT . '/src/lib/ACL.php'; +require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php'; if (session_status() !== PHP_SESSION_ACTIVE) session_start(); header('Content-Type: application/json'); -// ---- Auth + CSRF ----------------------------------------------------------- if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) { http_response_code(401); echo json_encode(['error' => 'Unauthorized']); @@ -24,98 +23,17 @@ if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) { exit; } -// ---- Helpers --------------------------------------------------------------- -function normalize_caps(array $row): array { - // booleanize known keys - $bool = function($v){ return !empty($v) && $v !== 'false' && $v !== 0; }; - $k = [ - 'view','viewOwn','upload','manage','share', - 'create','edit','rename','copy','move','delete','extract', - 'shareFile','shareFolder','write' - ]; - $out = []; - foreach ($k as $kk) $out[$kk] = $bool($row[$kk] ?? false); - - // BUSINESS RULES: - // A) Share Folder REQUIRES View (all). If shareFolder is true but view is false, force view=true. - if ($out['shareFolder'] && !$out['view']) { - $out['view'] = true; - } - - // B) Share File requires at least View (own). If neither view nor viewOwn set, set viewOwn=true. - if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) { - $out['viewOwn'] = true; - } - - // C) "write" does NOT imply view. It also does not imply granular here; ACL expands legacy write if present. - return $out; -} - -function sanitize_grants_map(array $grants): array { - $out = []; - foreach ($grants as $folder => $caps) { - if (!is_string($folder)) $folder = (string)$folder; - if (!is_array($caps)) $caps = []; - $out[$folder] = normalize_caps($caps); - } - return $out; -} - -function valid_user(string $u): bool { - return ($u !== '' && preg_match(REGEX_USER, $u)); -} - -// ---- Read JSON body -------------------------------------------------------- $raw = file_get_contents('php://input'); $in = json_decode((string)$raw, true); -if (!is_array($in)) { + +try { + $ctrl = new AclAdminController(); + $res = $ctrl->saveUserGrantsPayload($in ?? []); + echo json_encode($res, JSON_UNESCAPED_SLASHES); +} catch (InvalidArgumentException $e) { http_response_code(400); - echo json_encode(['error' => 'Invalid JSON']); - exit; -} - -// ---- Single user mode: { user, grants } ------------------------------------ -if (isset($in['user']) && isset($in['grants']) && is_array($in['grants'])) { - $user = trim((string)$in['user']); - if (!valid_user($user)) { - http_response_code(400); - echo json_encode(['error' => 'Invalid user']); - exit; - } - - $grants = sanitize_grants_map($in['grants']); - - try { - $res = ACL::applyUserGrantsAtomic($user, $grants); - echo json_encode($res, JSON_UNESCAPED_SLASHES); - exit; - } catch (Throwable $e) { - http_response_code(500); - echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]); - exit; - } -} - -// ---- Batch mode: { changes: [ { user, grants }, ... ] } -------------------- -if (isset($in['changes']) && is_array($in['changes'])) { - $updated = []; - foreach ($in['changes'] as $chg) { - if (!is_array($chg)) continue; - $user = trim((string)($chg['user'] ?? '')); - $gr = $chg['grants'] ?? null; - if (!valid_user($user) || !is_array($gr)) continue; - - try { - $res = ACL::applyUserGrantsAtomic($user, sanitize_grants_map($gr)); - $updated[$user] = $res['updated'] ?? []; - } catch (Throwable $e) { - $updated[$user] = ['error' => $e->getMessage()]; - } - } - echo json_encode(['ok' => true, 'updated' => $updated], JSON_UNESCAPED_SLASHES); - exit; -} - -// ---- Fallback -------------------------------------------------------------- -http_response_code(400); -echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']); + echo json_encode(['error' => $e->getMessage()]); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]); +} \ No newline at end of file diff --git a/public/api/pro/groups/list.php b/public/api/pro/groups/list.php new file mode 100644 index 0000000..682ca88 --- /dev/null +++ b/public/api/pro/groups/list.php @@ -0,0 +1,32 @@ +getProGroups(); + + echo json_encode([ + 'success' => true, + 'groups' => $groups, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} catch (Throwable $e) { + $code = $e instanceof InvalidArgumentException ? 400 : 500; + http_response_code($code); + echo json_encode([ + 'success' => false, + 'error' => 'Error loading groups: ' . $e->getMessage(), + ]); +} \ No newline at end of file diff --git a/public/api/pro/groups/save.php b/public/api/pro/groups/save.php new file mode 100644 index 0000000..f57dfcd --- /dev/null +++ b/public/api/pro/groups/save.php @@ -0,0 +1,51 @@ + false, 'error' => 'Method not allowed']); + return; + } + + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + + AdminController::requireAuth(); + AdminController::requireAdmin(); + AdminController::requireCsrf(); + + $raw = file_get_contents('php://input'); + $body = json_decode($raw, true); + if (!is_array($body)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid JSON payload.']); + return; + } + + $groups = $body['groups'] ?? null; + if (!is_array($groups)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid groups format.']); + return; + } + + $ctrl = new AdminController(); + $ctrl->saveProGroups($groups); + + echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} catch (Throwable $e) { + $code = $e instanceof InvalidArgumentException ? 400 : 500; + http_response_code($code); + echo json_encode([ + 'success' => false, + 'error' => 'Error saving groups: ' . $e->getMessage(), + ]); +} \ No newline at end of file diff --git a/public/api/pro/uploadBrandLogo.php b/public/api/pro/uploadBrandLogo.php new file mode 100644 index 0000000..3fc0384 --- /dev/null +++ b/public/api/pro/uploadBrandLogo.php @@ -0,0 +1,28 @@ + false, + 'error' => 'FileRise Pro is not active on this instance.' + ]); + exit; +} + +try { + $ctrl = new UserController(); + $ctrl->uploadBrandLogo(); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => 'Exception: ' . $e->getMessage(), + ]); +} diff --git a/public/css/styles.css b/public/css/styles.css index 303aa88..69ee746 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -6,7 +6,9 @@ img.logo{width:50px; height:50px; display:block;} #userPanelModal .modal-content, #adminPanelModal .modal-content, #userPermissionsModal .modal-content, -#userFlagsModal .modal-content{border-radius: var(--menu-radius);} +#userFlagsModal .modal-content, +#userGroupsModal .modal-content, +#groupAclModal .modal-content{border-radius: var(--menu-radius);} #fr-login-tip{min-height: 40px; max-width: 520px; margin: 8px auto 0; diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index 7175757..9f045dd 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -16,7 +16,7 @@ function normalizeLogoPath(raw) { const version = window.APP_VERSION || "dev"; // Hard-coded *FOR NOW* latest FileRise Pro bundle version for UI hints only. // Update this when I cut a new Pro ZIP. -const PRO_LATEST_BUNDLE_VERSION = 'v1.0.0'; +const PRO_LATEST_BUNDLE_VERSION = 'v1.0.1'; function getAdminTitle(isPro, proVersion) { const corePill = ` @@ -405,6 +405,27 @@ async function safeJson(res) { font-weight:600; min-width:0; /* allow child to be as wide as needed inside scroller */ } + .group-members-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + .group-member-pill { + display: inline-flex; + align-items: center; + padding: 2px 6px; + border-radius: 999px; + font-size: 11px; + background-color: #1e88e5; + color: #fff; + } + + .dark-mode .group-member-pill { + background-color: #1565c0; + color: #fff; + } + `; document.head.appendChild(style); })(); @@ -782,7 +803,7 @@ export function openAdminPanel() { groups User groups - Pro · Coming soon + Pro ` } @@ -821,7 +842,7 @@ export function openAdminPanel() { Use the core tools to manage users and per-folder access. - User groups and Client upload portals are planned FileRise Pro features. + User groups are available in Pro and Client upload portals are coming soon. `; @@ -862,8 +883,7 @@ export function openAdminPanel() { window.open("https://filerise.net", "_blank", "noopener"); return; } - // Placeholder for future Pro UI: - showToast("User groups management is coming soon in FileRise Pro."); + openUserGroupsModal(); }); } @@ -1894,6 +1914,97 @@ async function getUserGrants(username) { return (data && data.grants) ? data.grants : {}; } +function computeGroupGrantMaskForUser(username) { + const result = {}; + const uname = (username || "").trim().toLowerCase(); + if (!uname) return result; + if (!__groupsCache || typeof __groupsCache !== "object") return result; + + Object.keys(__groupsCache).forEach(gName => { + const g = __groupsCache[gName] || {}; + const members = Array.isArray(g.members) ? g.members : []; + const isMember = members.some(m => String(m || "").trim().toLowerCase() === uname); + if (!isMember) return; + + const grants = g.grants && typeof g.grants === "object" ? g.grants : {}; + Object.keys(grants).forEach(folder => { + const fg = grants[folder]; + if (!fg || typeof fg !== "object") return; + if (!result[folder]) result[folder] = {}; + Object.keys(fg).forEach(capKey => { + if (fg[capKey]) { + result[folder][capKey] = true; + } + }); + }); + }); + + return result; +} + +function applyGroupLocksForUser(username, grantsBox, groupMask, groupsForUser) { + if (!grantsBox || !groupMask) return; + + const groupLabels = (groupsForUser || []).map(name => { + const g = __groupsCache && __groupsCache[name] || {}; + return g.label || name; + }); + const labelStr = groupLabels.join(", "); + + const rows = grantsBox.querySelectorAll(".folder-access-row"); + rows.forEach(row => { + const folder = row.dataset.folder || ""; + const capsForFolder = groupMask[folder]; + if (!capsForFolder) return; + + Object.keys(capsForFolder).forEach(capKey => { + if (!capsForFolder[capKey]) return; + + // Map caps to actual columns we have in the UI + let uiCaps = []; + switch (capKey) { + case "view": + case "viewOwn": + case "manage": + case "create": + case "upload": + case "edit": + case "rename": + case "copy": + case "move": + case "delete": + case "extract": + case "shareFile": + case "shareFolder": + uiCaps = [capKey]; + break; + case "write": + uiCaps = ["create", "upload", "edit", "rename", "copy", "delete", "extract"]; + break; + case "share": + uiCaps = ["shareFile", "shareFolder"]; + break; + default: + // unknown / unsupported cap key in UI + return; + } + + uiCaps.forEach(c => { + const cb = row.querySelector(`input[type="checkbox"][data-cap="${c}"]`); + if (!cb) return; + cb.checked = true; + cb.disabled = true; + cb.setAttribute("data-hard-disabled", "1"); + + let baseTitle = "Granted via group"; + if (groupLabels.length > 1) baseTitle += "s"; + if (labelStr) baseTitle += `: ${labelStr}`; + cb.title = baseTitle + ". Edit group permissions in User groups to change."; + }); + }); + }); +} + function renderFolderGrantsUI(username, container, folders, grants) { container.innerHTML = ""; @@ -2325,6 +2436,430 @@ async function fetchAllUsers() { return await r.json(); } +async function fetchAllGroups() { + const res = await fetch('/api/pro/groups/list.php', { + credentials: 'include', + headers: { 'X-CSRF-Token': window.csrfToken || '' } + }); + const data = await safeJson(res); + // backend returns { success, groups: { name: {...} } } + return data && typeof data === 'object' && data.groups && typeof data.groups === 'object' + ? data.groups + : {}; +} + +async function saveAllGroups(groups) { + const res = await fetch('/api/pro/groups/save.php', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': window.csrfToken || '' + }, + body: JSON.stringify({ groups }) + }); + return await safeJson(res); +} + +let __groupsCache = {}; + +async function openUserGroupsModal() { + const isDark = document.body.classList.contains('dark-mode'); + const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)'; + const contentBg = isDark ? '#2c2c2c' : '#fff'; + const contentFg = isDark ? '#e0e0e0' : '#000'; + const borderCol = isDark ? '#555' : '#ccc'; + + let modal = document.getElementById('userGroupsModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'userGroupsModal'; + modal.style.cssText = ` + position:fixed; inset:0; background:${overlayBg}; + display:flex; align-items:center; justify-content:center; z-index:3650; + `; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + + document.getElementById('closeUserGroupsModal').onclick = () => (modal.style.display = 'none'); + document.getElementById('cancelUserGroups').onclick = () => (modal.style.display = 'none'); + document.getElementById('saveUserGroups').onclick = saveUserGroupsFromUI; + document.getElementById('addGroupBtn').onclick = addEmptyGroupRow; + } else { + modal.style.background = overlayBg; + const content = modal.querySelector('.modal-content'); + if (content) { + content.style.background = contentBg; + content.style.color = contentFg; + content.style.border = `1px solid ${borderCol}`; + } + } + + modal.style.display = 'flex'; + await loadUserGroupsList(); +} + +async function loadUserGroupsList(useCacheOnly) { + const body = document.getElementById('userGroupsBody'); + const status = document.getElementById('userGroupsStatus'); + if (!body) return; + + body.textContent = `${t('loading')}…`; + if (status) { + status.textContent = ''; + status.className = 'small text-muted'; + } + + try { + // Users always come fresh (or you could cache if you want) + const users = await fetchAllUsers(); + + let groups; + if (useCacheOnly && __groupsCache && Object.keys(__groupsCache).length) { + // When we’re just re-rendering after local edits, don’t clobber cache + groups = __groupsCache; + } else { + // Initial load, or explicit refresh – pull from server + groups = await fetchAllGroups(); + __groupsCache = groups || {}; + } + + const usernames = users + .map(u => String(u.username || '').trim()) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + + const groupNames = Object.keys(__groupsCache).sort((a, b) => a.localeCompare(b)); + if (!groupNames.length) { + body.innerHTML = `

${tf('no_groups_defined', 'No groups defined yet. Click “Add group” to create one.')}

`; + return; + } + + let html = ''; + groupNames.forEach(name => { + const g = __groupsCache[name] || {}; + const label = g.label || name; + const members = Array.isArray(g.members) ? g.members : []; + + const memberOptions = usernames.map(u => { + const sel = members.includes(u) ? 'selected' : ''; + return ``; + }).join(''); + + html += ` +
+
+
+ + +
+ +
+ +
+ + + + Hold Ctrl/Cmd to select multiple users. + +
+ +
+ +
+
+ `; + }); + + body.innerHTML = html; + + // After: body.innerHTML = html; + + // Show selected members as chips under each multi-select + body.querySelectorAll('select[data-group-field="members"]').forEach(sel => { + const chips = document.createElement('div'); + chips.className = 'group-members-chips'; + chips.style.marginTop = '4px'; + sel.insertAdjacentElement('afterend', chips); + + const renderChips = () => { + const names = Array.from(sel.selectedOptions).map(o => o.value); + if (!names.length) { + chips.innerHTML = `No members selected`; + return; + } + chips.innerHTML = names.map(n => ` + ${n} + `).join(' '); + }; + + sel.addEventListener('change', renderChips); + renderChips(); // initial + }); + + body.querySelectorAll('[data-group-action="delete"]').forEach(btn => { + btn.addEventListener('click', () => { + const card = btn.closest('[data-group-name]'); + const name = card && card.getAttribute('data-group-name'); + if (!name) return; + if (!confirm(`Delete group "${name}"?`)) return; + delete __groupsCache[name]; + card.remove(); + }); + }); + + body.querySelectorAll('[data-group-action="edit-acl"]').forEach(btn => { + btn.addEventListener('click', async () => { + const card = btn.closest('[data-group-name]'); + if (!card) return; + const nameInput = card.querySelector('input[data-group-field="name"]'); + const name = (nameInput && nameInput.value || '').trim(); + if (!name) { + showToast('Enter a group name first.'); + return; + } + await openGroupAclEditor(name); + }); + }); + } catch (e) { + console.error(e); + body.innerHTML = `

${tf('error_loading_groups', 'Error loading groups')}

`; + } +} + +function addEmptyGroupRow() { + if (!__groupsCache || typeof __groupsCache !== 'object') { + __groupsCache = {}; + } + let idx = 1; + let name = `group${idx}`; + while (__groupsCache[name]) { + idx += 1; + name = `group${idx}`; + } + __groupsCache[name] = { name, label: name, members: [], grants: {} }; + // Re-render using local cache only; don't clobber with server (which is still empty) + loadUserGroupsList(true); +} + +async function saveUserGroupsFromUI() { + const body = document.getElementById('userGroupsBody'); + const status = document.getElementById('userGroupsStatus'); + if (!body) return; + + const cards = body.querySelectorAll('[data-group-name]'); + const groups = {}; + + cards.forEach(card => { + const oldName = card.getAttribute('data-group-name') || ''; + const nameEl = card.querySelector('input[data-group-field="name"]'); + const labelEl = card.querySelector('input[data-group-field="label"]'); + const membersSel = card.querySelector('select[data-group-field="members"]'); + + const name = (nameEl && nameEl.value || '').trim(); + if (!name) return; + + const label = (labelEl && labelEl.value || '').trim() || name; + const members = Array.from(membersSel && membersSel.selectedOptions || []).map(o => o.value); + + const existing = __groupsCache[oldName] || __groupsCache[name] || { grants: {} }; + groups[name] = { + name, + label, + members, + grants: existing.grants || {} + }; + }); + + if (status) { + status.textContent = 'Saving groups…'; + status.className = 'small text-muted'; + } + + try { + const res = await saveAllGroups(groups); + if (!res.success) { + showToast(res.error || 'Error saving groups'); + if (status) { + status.textContent = 'Error saving groups.'; + status.className = 'small text-danger'; + } + return; + } + + __groupsCache = groups; + if (status) { + status.textContent = 'Groups saved.'; + status.className = 'small text-success'; + } + showToast('Groups saved.'); + } catch (e) { + console.error(e); + if (status) { + status.textContent = 'Error saving groups.'; + status.className = 'small text-danger'; + } + showToast('Error saving groups', 'error'); + } +} + +async function openGroupAclEditor(groupName) { + const isDark = document.body.classList.contains('dark-mode'); + const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)'; + const contentBg = isDark ? '#2c2c2c' : '#fff'; + const contentFg = isDark ? '#e0e0e0' : '#000'; + const borderCol = isDark ? '#555' : '#ccc'; + + let modal = document.getElementById('groupAclModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'groupAclModal'; + modal.style.cssText = ` + position:fixed; inset:0; background:${overlayBg}; + display:flex; align-items:center; justify-content:center; z-index:3700; + `; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + + document.getElementById('closeGroupAclModal').onclick = () => (modal.style.display = 'none'); + document.getElementById('cancelGroupAcl').onclick = () => (modal.style.display = 'none'); + document.getElementById('saveGroupAcl').onclick = saveGroupAclFromUI; + } else { + modal.style.background = overlayBg; + const content = modal.querySelector('.modal-content'); + if (content) { + content.style.background = contentBg; + content.style.color = contentFg; + content.style.border = `1px solid ${borderCol}`; + } + } + + const title = document.getElementById('groupAclTitle'); + if (title) title.textContent = `Group folder access: ${groupName}`; + + const body = document.getElementById('groupAclBody'); + if (body) body.textContent = `${t('loading')}…`; + + modal.dataset.groupName = groupName; + modal.style.display = 'flex'; + + const folders = await getAllFolders(true); + const grants = (__groupsCache[groupName] && __groupsCache[groupName].grants) || {}; + + if (body) { + body.textContent = ''; + const box = document.createElement('div'); + box.className = 'folder-grants-box'; + body.appendChild(box); + + renderFolderGrantsUI(groupName, box, ['root', ...folders.filter(f => f !== 'root')], grants); + } +} + +function saveGroupAclFromUI() { + const modal = document.getElementById('groupAclModal'); + if (!modal) return; + const groupName = modal.dataset.groupName; + if (!groupName) return; + + const body = document.getElementById('groupAclBody'); + if (!body) return; + const box = body.querySelector('.folder-grants-box'); + if (!box) return; + + const grants = collectGrantsFrom(box); + if (!__groupsCache[groupName]) { + __groupsCache[groupName] = { name: groupName, label: groupName, members: [], grants: {} }; + } + __groupsCache[groupName].grants = grants; + + showToast('Group folder access updated. Remember to Save groups.'); + modal.style.display = 'none'; +} + + async function fetchAllUserFlags() { const r = await fetch("/api/getUserPermissions.php", { credentials: "include" }); const data = await r.json(); @@ -2489,31 +3024,194 @@ async function loadUserPermissionsList() { listContainer.innerHTML = `

${t("loading")}…

`; try { - const usersRes = await fetch("/api/getUsers.php", { credentials: "include" }); - const usersData = await safeJson(usersRes); - const users = Array.isArray(usersData) ? usersData : (usersData.users || []); - if (!users.length) { + // Load users + groups together (folders separately) + const [usersRes, groupsMap] = await Promise.all([ + fetch("/api/getUsers.php", { credentials: "include" }).then(safeJson), + fetchAllGroups().catch(() => ({})) + ]); + + const users = Array.isArray(usersRes) ? usersRes : (usersRes.users || []); + const groups = groupsMap && typeof groupsMap === "object" ? groupsMap : {}; + + if (!users.length && !Object.keys(groups).length) { listContainer.innerHTML = "

" + t("no_users_found") + "

"; return; } - const folders = await getAllFolders(true); + // Keep cache in sync with the groups UI + __groupsCache = groups || {}; + const folders = await getAllFolders(true); + const orderedFolders = ["root", ...folders.filter(f => f !== "root")]; + + // Build map: username -> [groupName, ...] + const userGroupMap = {}; + Object.keys(groups).forEach(gName => { + const g = groups[gName] || {}; + const members = Array.isArray(g.members) ? g.members : []; + members.forEach(m => { + const u = String(m || "").trim(); + if (!u) return; + if (!userGroupMap[u]) userGroupMap[u] = []; + userGroupMap[u].push(gName); + }); + }); + + // Clear the container and render sections listContainer.innerHTML = ""; - users.forEach(user => { - const isAdmin = (user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin"; + +// ==================== +// Groups section (top) +// ==================== +const groupNames = Object.keys(groups).sort((a, b) => a.localeCompare(b)); +if (groupNames.length) { + const groupHeader = document.createElement("div"); + groupHeader.className = "muted"; + groupHeader.style.margin = "4px 0 6px"; + groupHeader.textContent = tf("groups_header", "Groups"); + listContainer.appendChild(groupHeader); + + groupNames.forEach(name => { + const g = groups[name] || {}; + const label = g.label || name; + const members = Array.isArray(g.members) ? g.members : []; + const membersSummary = members.length + ? members.join(", ") + : tf("no_members", "No members yet"); + + const row = document.createElement("div"); + row.classList.add("user-permission-row", "group-permission-row"); + row.setAttribute("data-group-name", name); + row.style.padding = "6px 0"; + + row.innerHTML = ` + + + `; + + // Safely inject dynamic text: + const labelEl = row.querySelector('.group-label'); + if (labelEl) { + labelEl.textContent = label; // no HTML, just text + } + + const membersEl = row.querySelector('.members-summary'); + if (membersEl) { + membersEl.textContent = `${tf("members_label", "Members")}: ${membersSummary}`; + } + + const header = row.querySelector(".user-perm-header"); + const details = row.querySelector(".user-perm-details"); + const caret = row.querySelector(".perm-caret"); + const grantsBox = row.querySelector(".folder-grants-box"); + + // Load this group's folder ACL (from __groupsCache) and show it read-only + async function ensureLoaded() { + if (grantsBox.dataset.loaded === "1") return; + try { + const group = __groupsCache[name] || {}; + const grants = group.grants || {}; + + renderFolderGrantsUI( + name, + grantsBox, + orderedFolders, + grants + ); + + // Make it clear: edit in User groups → Edit folder access + grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => { + cb.disabled = true; + cb.title = tf( + "edit_group_acl_in_user_groups", + "Group ACL is read-only here. Use User groups → Edit folder access to change it." + ); + }); + + grantsBox.dataset.loaded = "1"; + } catch (e) { + console.error(e); + grantsBox.innerHTML = `
${tf("error_loading_group_grants", "Error loading group grants")}
`; + } + } + + function toggleOpen() { + const willShow = details.style.display === "none"; + details.style.display = willShow ? "block" : "none"; + header.setAttribute("aria-expanded", willShow ? "true" : "false"); + caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)"; + if (willShow) ensureLoaded(); + } + + header.addEventListener("click", toggleOpen); + header.addEventListener("keydown", e => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleOpen(); + } + }); + + listContainer.appendChild(row); + }); + + // divider between groups and users + const hr = document.createElement("hr"); + hr.style.margin = "6px 0 10px"; + hr.style.border = "0"; + hr.style.borderTop = "1px solid rgba(0,0,0,0.08)"; + listContainer.appendChild(hr); +} + + // ================= + // Users section + // ================= + const sortedUsers = users.slice().sort((a, b) => { + const ua = String(a.username || "").toLowerCase(); + const ub = String(b.username || "").toLowerCase(); + return ua.localeCompare(ub); + }); + + sortedUsers.forEach(user => { + const username = String(user.username || "").trim(); + const isAdmin = + (user.role && String(user.role) === "1") || + username.toLowerCase() === "admin"; + + const groupsForUser = userGroupMap[username] || []; + const groupBadges = groupsForUser.length + ? (() => { + const labels = groupsForUser.map(gName => { + const g = groups[gName] || {}; + return g.label || gName; + }); + return `${tf("member_of_groups", "Groups")}: ${labels.join(", ")}`; + })() + : ""; const row = document.createElement("div"); row.classList.add("user-permission-row"); - row.setAttribute("data-username", user.username); - if (isAdmin) row.setAttribute("data-admin", "1"); // mark admins + row.setAttribute("data-username", username); + if (isAdmin) row.setAttribute("data-admin", "1"); row.style.padding = "6px 0"; row.innerHTML = ` @@ -2531,17 +3229,36 @@ async function loadUserPermissionsList() { if (grantsBox.dataset.loaded === "1") return; try { let grants; + const orderedFolders = ["root", ...folders.filter(f => f !== "root")]; + if (isAdmin) { // synthesize full access - const ordered = ["root", ...folders.filter(f => f !== "root")]; - grants = buildFullGrantsForAllFolders(ordered); - renderFolderGrantsUI(user.username, grantsBox, ordered, grants); - // disable all inputs + grants = buildFullGrantsForAllFolders(orderedFolders); + renderFolderGrantsUI(user.username, grantsBox, orderedFolders, grants); grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.disabled = true); } else { const userGrants = await getUserGrants(user.username); - renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], userGrants); + renderFolderGrantsUI(user.username, grantsBox, orderedFolders, userGrants); + + // NEW: overlay group-based grants so you can't uncheck them here + const groupMask = computeGroupGrantMaskForUser(user.username); + + // If you already build a userGroupMap somewhere, you can pass the exact groups; + // otherwise we can recompute the list of group names from __groupsCache: + const groupsForUser = []; + if (__groupsCache && typeof __groupsCache === "object") { + Object.keys(__groupsCache).forEach(gName => { + const g = __groupsCache[gName] || {}; + const members = Array.isArray(g.members) ? g.members : []; + if (members.some(m => String(m || "").trim().toLowerCase() === String(user.username || "").trim().toLowerCase())) { + groupsForUser.push(gName); + } + }); + } + + applyGroupLocksForUser(user.username, grantsBox, groupMask, groupsForUser); } + grantsBox.dataset.loaded = "1"; } catch (e) { console.error(e); diff --git a/public/js/i18n.js b/public/js/i18n.js index 6afc412..a3daa7e 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -233,7 +233,7 @@ const translations = { "error_generating_recovery_code": "Error generating recovery code", "error_loading_qr_code": "Error loading QR code.", "error_disabling_totp_setting": "Error disabling TOTP setting", - "user_management": "User Management", + "user_management": "Users, Groups & Access", "add_user": "Add User", "remove_user": "Remove User", "user_permissions": "User Permissions", diff --git a/src/controllers/AclAdminController.php b/src/controllers/AclAdminController.php new file mode 100644 index 0000000..30b179b --- /dev/null +++ b/src/controllers/AclAdminController.php @@ -0,0 +1,166 @@ + $_) { + $folders[$name] = true; + } + } + } + } + + $folderList = array_keys($folders); + if (!in_array('root', $folderList, true)) { + array_unshift($folderList, 'root'); + } + + $has = function(array $arr, string $u): bool { + foreach ($arr as $x) { + if (strcasecmp((string)$x, $u) === 0) return true; + } + return false; + }; + + $out = []; + foreach ($folderList as $f) { + $rec = ACL::explicitAll($f); + + $isOwner = $has($rec['owners'], $user); + $canViewAll = $isOwner || $has($rec['read'], $user); + $canViewOwn = $has($rec['read_own'], $user); + $canShare = $isOwner || $has($rec['share'], $user); + $canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user); + + 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, + 'write' => $has($rec['write'], $user) || $isOwner, + 'manage' => $isOwner, + 'share' => $canShare, + '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), + ]; + } + } + + return $out; + } + + public function saveUserGrantsPayload(array $payload): array + { + + $normalizeCaps = function (array $row): array { + $bool = function ($v) { + return !empty($v) && $v !== 'false' && $v !== 0; + }; + $k = [ + 'view','viewOwn','upload','manage','share', + 'create','edit','rename','copy','move','delete','extract', + 'shareFile','shareFolder','write' + ]; + $out = []; + foreach ($k as $kk) { + $out[$kk] = $bool($row[$kk] ?? false); + } + + if ($out['shareFolder'] && !$out['view']) { + $out['view'] = true; + } + if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) { + $out['viewOwn'] = true; + } + + return $out; + }; + + $sanitizeGrantsMap = function (array $grants) use ($normalizeCaps): array { + $out = []; + foreach ($grants as $folder => $caps) { + if (!is_string($folder)) $folder = (string)$folder; + if (!is_array($caps)) $caps = []; + $out[$folder] = $normalizeCaps($caps); + } + return $out; + }; + + $validUser = function (string $u): bool { + return ($u !== '' && preg_match(REGEX_USER, $u)); + }; + + // Single-user mode + if (isset($payload['user'], $payload['grants']) && is_array($payload['grants'])) { + $user = trim((string)$payload['user']); + if (!$validUser($user)) { + throw new InvalidArgumentException('Invalid user'); + } + + $grants = $sanitizeGrantsMap($payload['grants']); + + return ACL::applyUserGrantsAtomic($user, $grants); + } + + // Batch mode + if (isset($payload['changes']) && is_array($payload['changes'])) { + $updated = []; + foreach ($payload['changes'] as $chg) { + if (!is_array($chg)) continue; + $user = trim((string)($chg['user'] ?? '')); + $gr = $chg['grants'] ?? null; + if (!$validUser($user) || !is_array($gr)) continue; + + try { + $res = ACL::applyUserGrantsAtomic($user, $sanitizeGrantsMap($gr)); + $updated[$user] = $res['updated'] ?? []; + } catch (\Throwable $e) { + $updated[$user] = ['error' => $e->getMessage()]; + } + } + return ['ok' => true, 'updated' => $updated]; + } + + throw new InvalidArgumentException('Invalid payload: expected {user,grants} or {changes:[{user,grants}]}'); + } +} \ No newline at end of file diff --git a/src/controllers/AdminController.php b/src/controllers/AdminController.php index 3dfd7ce..c593c70 100644 --- a/src/controllers/AdminController.php +++ b/src/controllers/AdminController.php @@ -8,7 +8,7 @@ class AdminController { /** Enforce authentication (401). */ - private static function requireAuth(): void + public static function requireAuth(): void { if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); @@ -19,7 +19,7 @@ class AdminController } /** Enforce admin (401). */ - private static function requireAdmin(): void + public static function requireAdmin(): void { self::requireAuth(); @@ -69,7 +69,7 @@ class AdminController } /** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */ - private static function requireCsrf(): void + public static function requireCsrf(): void { $h = self::headersLower(); $token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? '')); @@ -272,6 +272,72 @@ public function setLicense(): void } } +public function getProGroups(): array +{ + if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) { + throw new RuntimeException('FileRise Pro is not active.'); + } + + $proGroupsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProGroups.php'; + if (!is_file($proGroupsPath)) { + throw new RuntimeException('ProGroups.php not found in Pro bundle.'); + } + + require_once $proGroupsPath; + + $store = new ProGroups(FR_PRO_BUNDLE_DIR); + $groups = $store->listGroups(); + + return $groups; +} + +/** + * @param array $groupsPayload Raw "groups" array from JSON body + */ +public function saveProGroups(array $groupsPayload): void +{ + if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) { + throw new RuntimeException('FileRise Pro is not active.'); + } + + $proGroupsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProGroups.php'; + if (!is_file($proGroupsPath)) { + throw new RuntimeException('ProGroups.php not found in Pro bundle.'); + } + + require_once $proGroupsPath; + + // Normalize / validate the payload into the canonical structure + if (!is_array($groupsPayload)) { + throw new InvalidArgumentException('Invalid groups format.'); + } + + $data = ['groups' => []]; + + foreach ($groupsPayload as $name => $info) { + $name = trim((string)$name); + if ($name === '') { + continue; + } + + $label = isset($info['label']) ? trim((string)$info['label']) : $name; + $members = isset($info['members']) && is_array($info['members']) ? $info['members'] : []; + $grants = isset($info['grants']) && is_array($info['grants']) ? $info['grants'] : []; + + $data['groups'][$name] = [ + 'name' => $name, + 'label' => $label, + 'members' => array_values(array_unique(array_map('strval', $members))), + 'grants' => $grants, + ]; + } + + $store = new ProGroups(FR_PRO_BUNDLE_DIR); + if (!$store->save($data)) { + throw new RuntimeException('Could not write groups.json'); + } +} + public function installProBundle(): void { header('Content-Type: application/json; charset=utf-8'); @@ -374,7 +440,6 @@ public function installProBundle(): void $installed = [ 'src' => [], - 'public' => [], 'docs' => [], ]; @@ -436,21 +501,6 @@ public function installProBundle(): void $targetPath = $bundleRoot . DIRECTORY_SEPARATOR . $relative; $category = 'src'; - } elseif (strpos($name, 'public/api/pro/') === 0) { - // e.g. public/api/pro/uploadBrandLogo.php - $relative = substr($name, strlen('public/api/pro/')); - if ($relative === '' || substr($relative, -1) === '/') { - continue; - } - - // Persist under bundle dir so it survives image rebuilds: - // users/pro/public/api/pro/... - $targetPath = $bundleRoot - . DIRECTORY_SEPARATOR . 'public' - . DIRECTORY_SEPARATOR . 'api' - . DIRECTORY_SEPARATOR . 'pro' - . DIRECTORY_SEPARATOR . $relative; - $category = 'public'; } else { // Skip anything outside these prefixes continue; diff --git a/src/lib/ACL.php b/src/lib/ACL.php index b043c58..61835ed 100644 --- a/src/lib/ACL.php +++ b/src/lib/ACL.php @@ -227,6 +227,166 @@ class ACL return $data; } + + /** + * Load Pro user groups from FR_PRO_BUNDLE_DIR/users/pro/groups.json. + * Returns a map: groupName => ['name','label','members'=>[],'grants'=>[]] + * When Pro is inactive or no file exists, returns an empty array. + */ + private static function loadGroupData(): array + { + if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) return []; + if (!defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) return []; + + static $loaded = false; + static $cache = []; + static $mtime = 0; + + $base = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\"); + if ($base === '') return []; + + $file = $base . DIRECTORY_SEPARATOR . 'groups.json'; + $mt = @filemtime($file) ?: 0; + + if ($loaded && $mtime === $mt) { + return $cache; + } + + $loaded = true; + $mtime = $mt; + if (!$mt || !is_file($file)) { + $cache = []; + return $cache; + } + + $raw = @file_get_contents($file); + if ($raw === false || $raw === '') { + $cache = []; + return $cache; + } + + $data = json_decode($raw, true); + if (!is_array($data)) { + $cache = []; + return $cache; + } + + $groups = isset($data['groups']) && is_array($data['groups']) ? $data['groups'] : $data; + $norm = []; + + foreach ($groups as $key => $g) { + if (!is_array($g)) continue; + $name = isset($g['name']) ? (string)$g['name'] : (string)$key; + $name = trim($name); + if ($name === '') continue; + + $g['name'] = $name; + $g['label'] = isset($g['label']) ? (string)$g['label'] : $name; + + if (!isset($g['members']) || !is_array($g['members'])) { + $g['members'] = []; + } else { + $g['members'] = array_values(array_unique(array_map('strval', $g['members']))); + } + + if (!isset($g['grants']) || !is_array($g['grants'])) { + $g['grants'] = []; + } + + $norm[$name] = $g; + } + + $cache = $norm; + return $cache; + } + + /** + * Map a group grants record for a single folder to a capability bucket. + * Supports both internal bucket keys and the UI-style keys: view, viewOwn, + * manage, shareFile, shareFolder. + */ + private static function groupGrantsCap(array $grants, string $capKey): bool + { + // Direct match (owners, read, write, share, read_own, create, upload, edit, rename, copy, move, delete, extract, share_file, share_folder) + if (array_key_exists($capKey, $grants) && $grants[$capKey] === true) { + return true; + } + + switch ($capKey) { + case 'read': + return !empty($grants['view']); + case 'read_own': + // Full view always implies own + if (!empty($grants['view'])) return true; + return !empty($grants['viewOwn']); + case 'share_file': + if (!empty($grants['share_file'])) return true; + return !empty($grants['shareFile']); + case 'share_folder': + if (!empty($grants['share_folder'])) return true; + return !empty($grants['shareFolder']); + case 'write': + case 'create': + case 'upload': + case 'edit': + case 'rename': + case 'copy': + case 'move': + case 'delete': + case 'extract': + if (!empty($grants[$capKey])) return true; + // Group "manage" implies all write-ish caps + return !empty($grants['manage']); + case 'share': + if (!empty($grants['share'])) return true; + // Manage can optionally imply share; this keeps UI simple + return !empty($grants['manage']); + } + + return false; + } + + /** + * Check whether any Pro group the user belongs to grants this cap for folder. + * Groups are additive only; they never remove access. + */ + private static function groupHasGrant(string $user, string $folder, string $capKey): bool + { + if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) return false; + $user = (string)$user; + if ($user === '') return false; + + $folder = self::normalizeFolder($folder); + if ($folder === '') $folder = 'root'; + + $groups = self::loadGroupData(); + if (!$groups) return false; + + foreach ($groups as $g) { + if (!is_array($g)) continue; + + $members = $g['members'] ?? []; + $isMember = false; + if (is_array($members)) { + foreach ($members as $m) { + if (strcasecmp((string)$m, $user) === 0) { + $isMember = true; + break; + } + } + } + if (!$isMember) continue; + + $folderGrants = $g['grants'][$folder] ?? null; + if (!is_array($folderGrants)) continue; + + if (self::groupGrantsCap($folderGrants, $capKey)) { + return true; + } + } + + return false; + } private static function save(array $acl): bool { $ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false; @@ -286,8 +446,20 @@ class ACL { $folder = self::normalizeFolder($folder); $capKey = ($cap === 'owner') ? 'owners' : $cap; - $arr = self::listFor($folder, $capKey); - foreach ($arr as $u) if (strcasecmp((string)$u, $user) === 0) return true; + + // 1) Core per-folder ACL buckets (folder_acl.json) + $arr = self::listFor($folder, $capKey); + foreach ($arr as $u) { + if (strcasecmp((string)$u, $user) === 0) { + return true; + } + } + + // 2) Pro user groups (if enabled) – additive only + if (self::groupHasGrant($user, $folder, $capKey)) { + return true; + } + return false; } @@ -620,4 +792,4 @@ class ACL // require full view too return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'read'); } -} +} \ No newline at end of file diff --git a/start.sh b/start.sh index b4a16d3..7a35fde 100644 --- a/start.sh +++ b/start.sh @@ -72,23 +72,6 @@ for d in uploads users metadata; do chmod 775 "${tgt}" done -# 2.4) Sync FileRise Pro public endpoints from persistent bundle -BUNDLE_PRO_PUBLIC="/var/www/users/pro/public/api/pro" -LIVE_PRO_PUBLIC="/var/www/public/api/pro" - -if [ -d "${BUNDLE_PRO_PUBLIC}" ]; then - echo "[startup] Syncing FileRise Pro public endpoints..." - mkdir -p "${LIVE_PRO_PUBLIC}" - - # Copy files from bundle to live api/pro (overwrite for upgrades) - cp -R "${BUNDLE_PRO_PUBLIC}/." "${LIVE_PRO_PUBLIC}/" || echo "[startup] Pro sync copy failed (continuing)" - - # Normalize ownership/permissions - chown -R www-data:www-data "${LIVE_PRO_PUBLIC}" || echo "[startup] chown api/pro failed (continuing)" - find "${LIVE_PRO_PUBLIC}" -type d -exec chmod 755 {} \; 2>/dev/null || true - find "${LIVE_PRO_PUBLIC}" -type f -exec chmod 644 {} \; 2>/dev/null || true -fi - # 3) Ensure PHP conf dir & set upload limits mkdir -p /etc/php/8.3/apache2/conf.d if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then