Compare commits

...

8 Commits

22 changed files with 2834 additions and 632 deletions

View File

@@ -1,6 +1,97 @@
# Changelog
## changes 11/18/2025 (v1.9.10)
## Changes 11/21/2025 (v1.9.14)
release(v1.9.14): inline folder rows, synced folder icons, and compact theme polish
- Add ACL-aware folder stats and byte counts in FolderModel::countVisible()
- Show subfolders inline as rows above files in table view (Explorer-style)
- Page folders + files together and wire folder rows into existing DnD and context menu flows
- Add folder action buttons (move/rename/color/share) with capability checks from /api/folder/capabilities.php
- Cache folder capabilities and owners to avoid repeat calls per row
- Add user settings to toggle folder strip and inline folder rows (stored in localStorage)
- Default itemsPerPage to 50 and remember current page across renders
- Sync inline folder icon size to file row height and tweak vertical alignment for different row heights
- Update table headers + i18n keys to use Name / Size / Modified / Created / Owner labels
- Compact and consolidate light/dark theme CSS, search pill, pagination, and font-size controls
- Tighten file action button hit areas and add specific styles for folder move/rename buttons
---
## Changes 11/20/2025 (v1.9.13)
release(v1.9.13): style(ui): compact dual-theme polish for lists, inputs, search & modals
- Added compact, unified light/dark theme for core surfaces (file list, upload, folder manager, admin panel).
- Updated modals, dropdown menus, and editor header to use the same modern panel styling in both themes.
- Restyled search bar into a pill-shaped control with a dedicated icon chip and better hover states.
- Refined pagination (Prev/Next) and font size (A-/A+) buttons to be smaller, rounded, and more consistent.
- Normalized input fields so borders render cleanly and focus states are consistent across the app.
- Tweaked button shadows so primary actions (Create/Upload) pop without feeling heavy in light mode.
- Polished dark-mode colors for tables, rows, toasts, and meta text for a more “app-like” feel.
---
## 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 users 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)
- media: add proper HTTP Range support to /api/file/download.php so HTML5
video/audio can seek correctly across all browsers (Brave/Chrome/Android/Windows).
- media: avoid buffering the entire file in memory; stream from disk with
200/206 responses and Accept-Ranges for smoother playback and faster start times.
- media: keep video progress tracking, watched badges, and status chip behavior
unchanged but now compatible with the new streaming endpoint.
- ui: update the folder strip to be responsive:
- desktop: keep the existing "chip" layout with icon above name.
- mobile: switch to inline rows `[icon] [name]` with reduced whitespace.
- ui: add simple lazy-loading for the folder strip so only the first batch of
folders is rendered initially, with a "Load more…" button to append chunks for
very large folder sets (stays friendly with 100k+ folders).
- misc: small CSS tidy-up around the folder strip classes to remove duplicates
and keep mobile/desktop behavior clearly separated.
---
## Changes 11/18/2025 (v1.9.10)
release(v1.9.10): add Pro bundle installer and admin panel polish

View File

@@ -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()]);
}

View File

@@ -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()]);
}

View File

@@ -0,0 +1,32 @@
<?php
// public/api/pro/groups/list.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
try {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
$ctrl = new AdminController();
$groups = $ctrl->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(),
]);
}

View File

@@ -0,0 +1,51 @@
<?php
// public/api/pro/groups/save.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['success' => 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(),
]);
}

View File

@@ -0,0 +1,28 @@
<?php
// public/api/pro/uploadBrandLogo.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
header('Content-Type: application/json; charset=utf-8');
// Pro-only gate
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode([
'success' => 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(),
]);
}

View File

@@ -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;
@@ -612,7 +614,8 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover{background-color: rgba
#fileList button.edit-btn{background-color: #007bff;
color: white;}
.rename-btn .material-icons,
#renameFolderBtn .material-icons{color: black !important;}
#renameFolderBtn .material-icons,
.folder-rename-btn .material-icons{color: black !important;}
#fileList table{background-color: transparent;
border-collapse: collapse !important;
border-spacing: 0 !important;
@@ -816,25 +819,38 @@ label{font-size: 0.9rem;}
.folder-actions .btn,
.folder-actions .material-icons{transition: none;}
}
#moveFolderBtn{background-color: #ff9800;
#moveFolderBtn,
.folder-move-btn{background-color: #ff9800;
border-color: #ff9800;
color: #fff;}
color: #fff;
}
#moveFolderBtn:hover:not(:disabled):not(.disabled),
.folder-move-btn:hover:not(:disabled):not(.disabled) {
background-color: #fb8c00; /* slightly darker */
border-color: #fb8c00;
}
/* Active/pressed (only when enabled) */
#moveFolderBtn:active:not(:disabled):not(.disabled),
.folder-move-btn:active:not(:disabled):not(.disabled) {
background-color: #f57c00;
border-color: #f57c00;
}
/* Disabled state (both attribute + .disabled class) */
#moveFolderBtn:disabled,
#moveFolderBtn.disabled,
.folder-move-btn:disabled,
.folder-move-btn.disabled {
background-color: #ffb74d;
border-color: #ffb74d;
color: #fff;
opacity: 0.55;
}
.row-selected{background-color: #f2f2f2 !important;}
.dark-mode .row-selected{background-color: #444 !important;
color: #fff !important;}
.custom-prev-next-btn{background-color: #e0e0e0;
color: #000;
border: none;
padding: 6px 12px;
font-size: 14px;
border-radius: 4px;
margin: 0 4px;
cursor: pointer;}
.custom-prev-next-btn:hover:not(:disabled){background-color: #d5d5d5;}
.dark-mode .custom-prev-next-btn{background-color: #444;
color: #fff;
border: none;}
.dark-mode .custom-prev-next-btn:hover:not(:disabled){background-color: #555;}
#customToast{position: fixed;
bottom: 20px;
right: 20px;
@@ -957,7 +973,8 @@ label{font-size: 0.9rem;}
transform: none !important;
box-shadow: none !important;}
}
.btn-group.btn-group-sm[aria-label="File actions"] .btn{padding: .2rem !important;
.btn-group.btn-group-sm[aria-label="File actions"] .btn{padding: .8rem !important;
width: 32px;
height: 32px;
line-height: 1 !important;
@@ -988,6 +1005,7 @@ label{font-size: 0.9rem;}
.btn-group.btn-group-sm[aria-label="File actions"] .btn .material-symbols-rounded{transition: none !important;
transform: none !important;}
}
.breadcrumb-link{cursor: pointer;
color: #007bff;
text-decoration: underline;}
@@ -1703,8 +1721,6 @@ body.dark-mode .folder-strip-container .folder-item:hover{background-color: rgba
--filr-folder-stroke:#a87312;
--filr-paper-fill: #ffffff;
--filr-paper-stroke: #9fb3d6;
--row-h: 28px;
--twisty: 24px;
--twisty-gap: -5px;
@@ -1851,7 +1867,6 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
align-items: center;
gap: 8px;
justify-content: center;
border-radius: 10px;
border: 1px solid var(--tree-ghost-border);
background: var(--tree-ghost-bg);
color: var(--tree-ghost-fg);
@@ -1887,4 +1902,193 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
color: #111;}
.dark-mode .upload-resume-banner-inner .material-icons,
.dark-mode .folder-badge .material-icons{background-color: transparent;
color: #f5f5f5;}
color: #f5f5f5;}
/* Base strip container */
.folder-strip-container {
margin-bottom: 6px;
}
/* Base item layout */
.folder-strip-container .folder-item {
display: flex;
min-width: 0;
}
.folder-strip-container .folder-svg {
flex: 0 0 auto;
line-height: 0;
}
.folder-strip-container .folder-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* --- Desktop: chips, icon above name --- */
.folder-strip-container.folder-strip-desktop {
display: flex;
align-items: center;
overflow-x: auto;
padding: 4px 8px;
}
.folder-strip-container.folder-strip-desktop .folder-item {
flex-direction: column; /* icon on top, name under */
align-items: center;
gap: 4px;
}
.folder-strip-container.folder-strip-desktop .folder-name {
text-align: center;
max-width: 120px;
}
/* --- Mobile: stacked rows, icon left of name --- */
.folder-strip-container.folder-strip-mobile {
display: block;
max-height: 220px;
overflow-y: auto;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,.08);
background: rgba(0,0,0,.02);
}
.folder-strip-container.folder-strip-mobile .folder-item {
width: 100%;
flex-direction: row; /* icon left, name right */
align-items: center;
gap: 6px;
padding: 8px 8px;
border-radius: 6px;
margin-bottom: 4px;
}
.folder-strip-container.folder-strip-mobile .folder-name {
flex: 1 1 auto;
text-align: left;
transform: translate(8px, 4px);
}
.folder-strip-container.folder-strip-mobile .folder-item:hover {
background: rgba(0,0,0,.04);
}
.folder-strip-container.folder-strip-mobile .folder-item.selected {
background: rgba(59,130,246,.12);
}
/* Load-more button */
.folder-strip-load-more {
display: block;
width: 100%;
margin: 4px 0 0;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid rgba(0,0,0,.15);
background: rgba(0,0,0,.02);
font-size: 0.85rem;
text-align: center;
cursor: pointer;
}
/* ============================================
FileRise polish compact theme layer
============================================ */
:root{--filr-radius-lg:14px;--filr-radius-xl:18px;--filr-shadow-soft:0 12px 35px rgba(15,23,42,.14);--filr-shadow-subtle:0 8px 20px rgba(15,23,42,.10);--filr-header-blur:18px;--filr-transition-fast:150ms ease-out;--filr-transition-med:220ms cubic-bezier(.22,.61,.36,1);--fr-bg-dark:#0f0f0f;--fr-surface-dark:#212121;--fr-surface-dark-2:#181818;--fr-border-dark:#303030;--fr-muted-dark:#aaaaaa;--fr-bg-light:#f9f9f9;--fr-surface-light:#ffffff;--fr-surface-light-2:#f1f1f1;--fr-border-light:#e5e5e5;--fr-muted-light:#606060}
.btn-pro-admin{background:linear-gradient(135deg,#ff9800,#ff5722);border-color:#ff9800;color:#1b0f00!important;font-weight:600;box-shadow:0 0 10px rgba(255,152,0,.4)}
#customToast{border-radius:999px}
#folderTreeContainer .folder-row{border-radius:8px}
.btn,#customChooseBtn, #colorFolderModal .btn-ghost, #cancelMoveFolder, #confirmMoveFolder, #cancelRenameFolder, #submitRenameFolder, #cancelDeleteFolder, #confirmDeleteFolder, #cancelCreateFolder, #submitCreateFolder{border-radius:999px;font-weight:500;border:1px solid transparent;transition:background-color var(--filr-transition-fast),box-shadow var(--filr-transition-fast),transform var(--filr-transition-fast),border-color var(--filr-transition-fast)}
.btn-primary,#createBtn,#uploadBtn,#submitCreateFolder,#submitRenameFolder,#confirmMoveFolder{box-shadow:0 2px 4px rgba(0,0,0,.6)}
.btn-primary:hover,#createBtn:hover,#uploadBtn:hover,#submitCreateFolder:hover,#submitRenameFolder:hover,#confirmMoveFolder:hover{filter:brightness(1.04);transform:translateY(-1px);box-shadow:0 10px 22px rgba(0,140,180,.28)}
#deleteSelectedBtn,#deleteAllBtn,#deleteTrashSelectedBtn,#deleteFolderBtn,#confirmDeleteFolder{border-color:rgba(248,113,113,.9);box-shadow:0 8px 18px rgba(248,113,113,.35)}
input[type=text],input[type=password],input[type=email],input[type=url],select,textarea{border-radius:10px;padding:8px 10px;font-size:.92rem;transition:border-color var(--filr-transition-fast),box-shadow var(--filr-transition-fast),background-color var(--filr-transition-fast)}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--filr-accent-500);box-shadow:0 0 0 1px var(--filr-accent-ring)}
.modal{backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
#fileListContainer,#uploadCard,#folderManagementCard,.card,.admin-panel-content{border-radius:var(--filr-radius-xl);border:1px solid rgba(15,23,42,.06);background:#ffffff;box-shadow:var(--filr-shadow-subtle)}
body{min-height:100vh}
body.dark-mode{background:var(--fr-bg-dark)!important;color:#f1f1f1!important;background-image:none!important}
body.dark-mode #fileListContainer,body.dark-mode #uploadCard,body.dark-mode #folderManagementCard,body.dark-mode .card,body.dark-mode .admin-panel-content,body.dark-mode .media-topbar{background:var(--fr-surface-dark)!important;border-color:var(--fr-border-dark)!important;box-shadow:0 1px 4px rgba(0,0,0,.9)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
body.dark-mode #fileListContainer::before,body.dark-mode #uploadCard::before,body.dark-mode #folderManagementCard::before,body.dark-mode .card::before,body.dark-mode .admin-panel-content::before{box-shadow:none!important}
body.dark-mode .card-header,body.dark-mode .custom-folder-card-body .drag-header{background:var(--fr-surface-dark-2)!important;border-bottom:1px solid var(--fr-border-dark)!important}
body.dark-mode #fileList table thead th{background:var(--fr-surface-dark-2)!important;border-bottom:1px solid var(--fr-border-dark)!important}
body.dark-mode #fileList table.filr-table tbody tr.selected>td,body.dark-mode #fileList table.filr-table tbody tr.row-selected>td,body.dark-mode #fileList table.filr-table tbody tr.selected-row>td,body.dark-mode #fileList table.filr-table tbody tr.is-selected>td{background:rgba(62,166,255,.16)!important;box-shadow:none!important}
body.dark-mode .modal{background-color:rgba(0,0,0,.65)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
body.dark-mode .modal .modal-content,body.dark-mode .editor-modal,body.dark-mode .image-preview-modal-content,body.dark-mode #restoreFilesModal .modal-content,body.dark-mode #downloadProgressModal .modal-content{background:var(--fr-surface-dark)!important;border-radius:12px!important;border:1px solid var(--fr-border-dark)!important;box-shadow:0 8px 24px rgba(0,0,0,.9)!important}
body.dark-mode .modal .modal-content::before,body.dark-mode .editor-modal::before,body.dark-mode .image-preview-modal-content::before,body.dark-mode #restoreFilesModal .modal-content::before,body.dark-mode #downloadProgressModal .modal-content::before{box-shadow:none!important}
body.dark-mode input[type=text],body.dark-mode input[type=password],body.dark-mode input[type=email],body.dark-mode input[type=url],body.dark-mode select,body.dark-mode textarea{background:#121212!important;border-color:#3d3d3d!important;color:#f1f1f1!important}
body.dark-mode input::placeholder,body.dark-mode textarea::placeholder{color:#777!important}
body.dark-mode input:focus,body.dark-mode select:focus,body.dark-mode textarea:focus{border-color:#3ea6ff!important;box-shadow:0 0 0 1px rgba(62,166,255,.7)!important}
body.dark-mode #deleteSelectedBtn,body.dark-mode #deleteAllBtn,body.dark-mode #deleteTrashSelectedBtn,#deleteFolderBtn,#confirmDeleteFolder{background-color:#b3261e!important;border-color:#b3261e!important;box-shadow:0 4px 10px rgba(0,0,0,.7)!important}
body.dark-mode .folder-strip-container.folder-strip-mobile{background:var(--fr-surface-dark-2)!important;border:1px solid var(--fr-border-dark)!important}
body.dark-mode #customToast{background:#212121!important;border:1px solid var(--fr-border-dark)!important;box-shadow:0 8px 20px rgba(0,0,0,.9)!important}
body.dark-mode #fileSummary{color:var(--fr-muted-dark)!important}
body.dark-mode #createMenu,body.dark-mode .user-dropdown .user-menu,body.dark-mode #fileContextMenu,body.dark-mode #folderContextMenu,body.dark-mode #folderManagerContextMenu,body.dark-mode #adminPanelModal .modal-content,body.dark-mode #userPermissionsModal .modal-content,body.dark-mode #userFlagsModal .modal-content,body.dark-mode #userGroupsModal .modal-content,body.dark-mode #userPanelModal .modal-content,body.dark-mode #groupAclModal .modal-content,body.dark-mode .editor-modal,body.dark-mode #filePreviewModal .modal-content,body.dark-mode #loginForm,body.dark-mode .editor-header{background:var(--fr-surface-dark)!important;border:1px solid var(--fr-border-dark)!important;color:#f1f1f1!important;border-radius:12px!important;box-shadow:0 8px 24px rgba(0,0,0,.9)!important}
body.dark-mode .user-dropdown .user-menu,body.dark-mode #createMenu,body.dark-mode #fileContextMenu,body.dark-mode #folderContextMenu,body.dark-mode #folderManagerContextMenu{background-clip:padding-box}
body:not(.dark-mode){background:var(--fr-bg-light)!important;color:#111!important;background-image:none!important}
body:not(.dark-mode) #fileListContainer,body:not(.dark-mode) #uploadCard,body:not(.dark-mode) #folderManagementCard,body:not(.dark-mode) .card,body:not(.dark-mode) .admin-panel-content{background:var(--fr-surface-light)!important;border-color:var(--fr-border-light)!important;box-shadow:0 3px 8px rgba(0,0,0,.04)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
body:not(.dark-mode) #fileListContainer::before,body:not(.dark-mode) #uploadCard::before,body:not(.dark-mode) #folderManagementCard::before,body:not(.dark-mode) .card::before,body:not(.dark-mode) .admin-panel-content::before{box-shadow:none!important}
body:not(.dark-mode) .card-header,body:not(.dark-mode) .custom-folder-card-body .drag-header{background:var(--fr-surface-light-2)!important;border-bottom:1px solid var(--fr-border-light)!important}
body:not(.dark-mode) #fileList table thead th{background:var(--fr-surface-light-2)!important;border-bottom:1px solid var(--fr-border-light)!important}
body:not(.dark-mode) #fileList table.filr-table tbody tr.selected>td,body:not(.dark-mode) #fileList table.filr-table tbody tr.row-selected>td,body:not(.dark-mode) #fileList table.filr-table tbody tr.selected-row>td,body:not(.dark-mode) #fileList table.filr-table tbody tr.is-selected>td{background:rgba(33,150,243,.12)!important;box-shadow:none!important}
body:not(.dark-mode) .modal{background-color:rgba(0,0,0,.4)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
body:not(.dark-mode) .modal .modal-content,body:not(.dark-mode) .editor-modal,body:not(.dark-mode) .image-preview-modal-content,body:not(.dark-mode) #restoreFilesModal .modal-content,body:not(.dark-mode) #downloadProgressModal .modal-content{background:var(--fr-surface-light)!important;border-radius:12px!important;border:1px solid var(--fr-border-light)!important;box-shadow:0 8px 24px rgba(0,0,0,.18)!important}
body:not(.dark-mode) .modal .modal-content::before,body:not(.dark-mode) .editor-modal::before,body:not(.dark-mode) .image-preview-modal-content::before,body:not(.dark-mode) #restoreFilesModal .modal-content::before,body:not(.dark-mode) #downloadProgressModal .modal-content::before{box-shadow:none!important}
body:not(.dark-mode) input[type=text],body:not(.dark-mode) input[type=password],body:not(.dark-mode) input[type=email],body:not(.dark-mode) input[type=url],body:not(.dark-mode) select,body:not(.dark-mode) textarea{background:#fff!important;border-color:#d0d0d0!important;color:#111!important}
body:not(.dark-mode) input::placeholder,body:not(.dark-mode) textarea::placeholder{color:#9e9e9e!important}
body:not(.dark-mode) input:focus,body:not(.dark-mode) select:focus,body:not(.dark-mode) textarea:focus{border-color:#2196f3!important;box-shadow:0 0 0 1px rgba(33,150,243,.55)!important}
body:not(.dark-mode) #deleteSelectedBtn,body:not(.dark-mode) #deleteAllBtn,body:not(.dark-mode) #deleteTrashSelectedBtn{box-shadow:0 2px 6px rgba(244,67,54,.3)!important}
body:not(.dark-mode) .folder-strip-container.folder-strip-mobile{background:#f1f1f1!important;border:1px solid var(--fr-border-light)!important}
body:not(.dark-mode) #customToast{background:#212121!important;color:#fff!important;border:1px solid #000!important;box-shadow:0 8px 18px rgba(0,0,0,.45)!important}
body:not(.dark-mode) #fileSummary{color:var(--fr-muted-light)!important}
body:not(.dark-mode) #createMenu,body:not(.dark-mode) .user-dropdown .user-menu,body:not(.dark-mode) #fileContextMenu,body:not(.dark-mode) #folderContextMenu,body:not(.dark-mode) #folderManagerContextMenu,body:not(.dark-mode) #adminPanelModal .modal-content,body:not(.dark-mode) #userPermissionsModal .modal-content,body:not(.dark-mode) #userFlagsModal .modal-content,body:not(.dark-mode) #userGroupsModal .modal-content,body:not(.dark-mode) #userPanelModal .modal-content,body:not(.dark-mode) #groupAclModal .modal-content,body:not(.dark-mode) .editor-modal,body:not(.dark-mode) #filePreviewModal .modal-content,body:not(.dark-mode) #loginForm,body:not(.dark-mode) .editor-header{background:var(--fr-surface-light)!important;border:1px solid var(--fr-border-light)!important;color:#111!important;border-radius:12px!important;box-shadow:0 4px 12px rgba(0,0,0,.12)!important}
#searchIcon{display:inline-flex;align-items:center;justify-content:center;width:38px;height:36px;padding:0;border-radius:999px 0 0 999px;border:1px solid rgba(0,0,0,.18);border-right:none;background:#fff;cursor:pointer;box-shadow:none;transform:none}
#searchIcon .material-icons{font-size:20px;line-height:1;color:#555}
#searchIcon:hover{background:#f5f5f5}
#searchIcon+#searchInput{height:36px;border-radius:0 999px 999px 0;border-left:none;padding-top:6px;padding-bottom:6px}
body.dark-mode #searchIcon{background:#212121;border-color:#3d3d3d}
body.dark-mode #searchIcon .material-icons{color:#f1f1f1}
body.dark-mode #searchIcon:hover{background:#303030}
body.dark-mode #searchIcon+#searchInput{border-left:none}
#advancedSearchToggle{border-radius:999px;border:1px solid #d0d0d0;padding:6px 12px;font-size:.9rem;background:#f5f5f5;color:#333;cursor:pointer;display:inline-flex;align-items:center;gap:4px;margin-right:8px;transition:background .15s ease,box-shadow .15s ease,transform .1s ease}
#advancedSearchToggle:hover,#advancedSearchToggle:focus-visible{background:#e8e8e8;box-shadow:0 1px 4px rgba(0,0,0,.16);outline:none;transform:translateY(-1px)}
.dark-mode #advancedSearchToggle{background:#2a2a2a;border-color:#444;color:#f1f1f1}
.dark-mode #advancedSearchToggle:hover,.dark-mode #advancedSearchToggle:focus-visible{background:#333;box-shadow:0 1px 4px rgba(0,0,0,.5)}
.custom-prev-next-btn{display:inline-flex;align-items:center;justify-content:center;min-width:64px;padding:6px 14px;font-size:13px;font-weight:500;border-radius:999px;border:1px solid rgba(0,0,0,.14);background:#f1f1f1;color:#111;cursor:pointer;transition:background-color 140ms ease-out,border-color 140ms ease-out,box-shadow 140ms ease-out,transform 120ms ease-out}
.custom-prev-next-btn:not(:disabled):hover{background:#e5e5e5;border-color:rgba(0,0,0,.22);box-shadow:0 2px 6px rgba(0,0,0,.18);transform:translateY(-1px)}
.custom-prev-next-btn:not(:disabled):active{transform:translateY(0);box-shadow:0 1px 3px rgba(0,0,0,.25)}
.custom-prev-next-btn:disabled{opacity:.5;cursor:default;box-shadow:none}
body.dark-mode .custom-prev-next-btn{background:#212121;border-color:#3d3d3d;color:#f1f1f1}
body.dark-mode .custom-prev-next-btn:not(:disabled):hover{background:#2a2a2a;border-color:#4a4a4a;box-shadow:0 2px 6px rgba(0,0,0,.7)}
input[type=text]:not(#searchInput),input[type=password],input[type=email],input[type=url],input[type=number],textarea,select{border:1px solid rgba(148,163,184,.6)!important;border-radius:10px;background:#ffffff;box-sizing:border-box}
#decreaseFont,#increaseFont{display:inline-flex;align-items:center;justify-content:center;margin-top:5px;height:24px;min-width:30px;padding:2px 8px;font-size:11px;font-weight:500;line-height:1;border-radius:999px;border:1px solid rgba(0,0,0,.16);background:#f5f5f5;color:#222;cursor:pointer;margin-left:4px;transition:background-color 140ms ease-out,border-color 140ms ease-out,box-shadow 140ms ease-out,transform 120ms ease-out}
#decreaseFont:not(:disabled):hover,#increaseFont:not(:disabled):hover{background:#e8e8e8;border-color:rgba(0,0,0,.24);box-shadow:0 1px 4px rgba(0,0,0,.18);transform:translateY(-1px)}
#decreaseFont:not(:disabled):active,#increaseFont:not(:disabled):active{transform:translateY(5px);box-shadow:0 1px 2px rgba(0,0,0,.25)}
#decreaseFont:disabled,#increaseFont:disabled{opacity:.5;cursor:default;box-shadow:none}
body.dark-mode #decreaseFont,body.dark-mode #increaseFont{background:#212121;border-color:#3d3d3d;color:#f1f1f1}
body.dark-mode #decreaseFont:not(:disabled):hover,body.dark-mode #increaseFont:not(:disabled):hover{background:#2a2a2a;border-color:#4a4a4a;box-shadow:0 1px 4px rgba(0,0,0,.7)}
#closeEditorX{margin-right:10px}
#fileList .folder-row-icon .folder-front{fill:var(--filr-folder-front,#f6b84e);stroke:var(--filr-folder-stroke,#a87312);stroke-width:.5;stroke-linejoin:round;stroke-linecap:round}
#fileList .folder-row-icon .folder-back{fill:var(--filr-folder-back,#fcd68a);stroke:var(--filr-folder-stroke,#a87312);stroke-width:.5;stroke-linejoin:round;stroke-linecap:round}
#fileList .folder-row-icon .paper{fill:#fff;stroke:#b2c2db;stroke-width:1;vector-effect:non-scaling-stroke}
#fileList .folder-row-icon .paper-fold{fill:#b2c2db}
#fileList .folder-row-icon .paper-line{stroke:#b2c2db;stroke-width:1;stroke-linecap:round;fill:none;vector-effect:non-scaling-stroke}
#fileList .folder-row-icon .paper-ink{stroke:#4da3ff;stroke-width:.9;stroke-linecap:round;stroke-linejoin:round;fill:none;opacity:.85}
#fileList .folder-row-icon .lip-highlight{fill:none;vector-effect:non-scaling-stroke;stroke-linecap:round;stroke-linejoin:round}
#fileList .folder-row-name{font-weight:500;margin-right:4px}
#fileList .folder-row-meta{margin-left:4px;opacity:.75;font-size:.86em}
#fileList tbody tr.folder-row{height:var(--file-row-height,44px);cursor:pointer}
#fileList tbody tr.folder-row .folder-name-cell{padding-top:0;padding-bottom:0}
#fileList tbody tr.folder-row .folder-row-inner{cursor:inherit}
#fileList tbody tr.folder-row .folder-icon-cell{text-align:left;vertical-align:middle}
#fileList tbody tr.folder-row .folder-row-icon svg{display:block}
.folder-row-icon{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin-right:8px;position:relative;left:-8px;top:5px}
.folder-row-inner{display:flex;align-items:center}
#fileList table.filr-table th.checkbox-col,#fileList table.filr-table td.checkbox-col,#fileList table.filr-table td.folder-icon-cell{width:30px!important;max-width:30px!important}
#fileList tr.folder-row.folder-row-droptarget{background:var(--filr-accent-50,rgba(250,204,21,.12));box-shadow:inset 0 0 0 1px var(--filr-accent-400,rgba(250,204,21,.6))}
#fileList tr.folder-row.folder-row-droptarget .folder-row-name{font-weight:600}
#fileList table.filr-table tbody tr.folder-row>td{padding-top:0!important;padding-bottom:0!important}
#fileList table.filr-table tbody tr.folder-row>td.folder-icon-cell{overflow:visible}
#fileList tr.folder-row .folder-row-inner,#fileList tr.folder-row .folder-row-name{cursor:inherit}

View File

@@ -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() {
<i class="material-icons">groups</i>
<span>User groups</span>
</button>
<span class="btn-pro-pill">Pro · Coming soon</span>
<span class="btn-pro-pill">Pro</span>
</div>
`
}
@@ -821,7 +842,7 @@ export function openAdminPanel() {
<small class="text-muted d-block" style="margin-top:6px;">
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.
</small>
`;
@@ -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 = `
<div class="modal-content"
style="background:${contentBg}; color:${contentFg};
padding:16px; max-width:980px; width:95%;
position:relative;
border:1px solid ${borderCol}; max-height:90vh; overflow:auto;">
<span id="closeUserGroupsModal"
class="editor-close-btn"
style="right:8px; top:8px;">&times;</span>
<h3>User groups</h3>
<p class="muted" style="margin-top:-6px;">
Define named groups, assign users to them, and attach folder access
just like per-user ACL. Group access is additive to user access.
</p>
<div class="d-flex justify-content-between align-items-center" style="margin:8px 0 10px;">
<button type="button" id="addGroupBtn" class="btn btn-sm btn-success">
<i class="material-icons" style="font-size:16px;">group_add</i>
<span style="margin-left:4px;">Add group</span>
</button>
<span id="userGroupsStatus" class="small text-muted"></span>
</div>
<div id="userGroupsBody" style="max-height:60vh; overflow:auto; margin-bottom:12px;">
${t('loading')}
</div>
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button type="button" id="cancelUserGroups" class="btn btn-secondary">${t('cancel')}</button>
<button type="button" id="saveUserGroups" class="btn btn-primary">${t('save_settings')}</button>
</div>
</div>
`;
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 were just re-rendering after local edits, dont 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 = `<p class="muted">${tf('no_groups_defined', 'No groups defined yet. Click “Add group” to create one.')}</p>`;
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 `<option value="${u}" ${sel}>${u}</option>`;
}).join('');
html += `
<div class="card" data-group-name="${name}" style="margin-bottom:10px; padding:8px 10px; border-radius:8px;">
<div class="d-flex justify-content-between align-items-center" style="gap:8px; flex-wrap:wrap;">
<div class="d-flex align-items-center" style="gap:6px; flex-wrap:wrap;">
<label style="margin:0; font-weight:600;">
Group name:
<input type="text"
class="form-control form-control-sm"
data-group-field="name"
value="${name}"
style="display:inline-block; width:160px; margin-left:4px;" />
</label>
<label style="margin:0;">
Label:
<input type="text"
class="form-control form-control-sm"
data-group-field="label"
value="${(g.label || '').replace(/"/g, '&quot;')}"
style="display:inline-block; width:200px; margin-left:4px;" />
</label>
</div>
<button type="button"
class="btn btn-sm btn-danger"
data-group-action="delete">
<i class="material-icons" style="font-size:22px;">delete</i>
</button>
</div>
<div style="margin-top:8px;">
<label style="font-size:12px; font-weight:600;">Members:</label>
<select multiple
class="form-control form-control-sm"
data-group-field="members"
size="${Math.min(Math.max(usernames.length, 3), 8)}">
${memberOptions}
</select>
<small class="text-muted">
Hold Ctrl/Cmd to select multiple users.
</small>
</div>
<div style="margin-top:8px;">
<button type="button"
class="btn btn-sm btn-secondary"
data-group-action="edit-acl">
Edit folder access
</button>
</div>
</div>
`;
});
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 = `<span class="muted" style="font-size:11px;">No members selected</span>`;
return;
}
chips.innerHTML = names.map(n => `
<span class="group-member-pill">${n}</span>
`).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 = `<p class="muted">${tf('error_loading_groups', 'Error loading groups')}</p>`;
}
}
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 = `
<div class="modal-content"
style="background:${contentBg}; color:${contentFg};
padding:16px; max-width:1300px; width:99%;
position:relative;
border:1px solid ${borderCol}; max-height:90vh; overflow:auto;">
<span id="closeGroupAclModal"
class="editor-close-btn"
style="right:8px; top:8px;">&times;</span>
<h3 id="groupAclTitle">Group folder access</h3>
<div class="muted" style="margin:-4px 0 10px;">
Group grants are merged with each members own folder access. They never reduce access.
</div>
<div id="groupAclBody" style="max-height:70vh; overflow-y:auto; margin-bottom:12px;"></div>
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button type="button" id="cancelGroupAcl" class="btn btn-secondary">${t('cancel')}</button>
<button type="button" id="saveGroupAcl" class="btn btn-primary">${t('save_permissions')}</button>
</div>
</div>
`;
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 = `<p>${t("loading")}…</p>`;
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 = "<p>" + t("no_users_found") + "</p>";
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 = `
<div class="user-perm-header" tabindex="0" role="button" aria-expanded="false"
style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 8px;border-radius:12px;">
<span class="perm-caret" style="display:inline-block; transform: rotate(-90deg); transition: transform 120ms ease;">▸</span>
<i class="material-icons" style="font-size:18px;">group</i>
<strong class="group-label"></strong>
<span class="muted" style="margin-left:4px;font-size:11px;">
(${tf("group_label", "group")})
</span>
<span class="muted members-summary" style="margin-left:auto;font-size:11px;"></span>
</div>
<div class="user-perm-details" style="display:none; margin:8px 0 12px;">
<div class="folder-grants-box" data-loaded="0"></div>
</div>
`;
// 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 = `<div class="muted">${tf("error_loading_group_grants", "Error loading group grants")}</div>`;
}
}
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 `<span class="muted" style="margin-left:8px;font-size:11px;">${tf("member_of_groups", "Groups")}: ${labels.join(", ")}</span>`;
})()
: "";
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 = `
<div class="user-perm-header" tabindex="0" role="button" aria-expanded="false"
style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 8px;border-radius:6px;">
style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 8px;border-radius:12px;">
<span class="perm-caret" style="display:inline-block; transform: rotate(-90deg); transition: transform 120ms ease;">▸</span>
<strong>${user.username}</strong>
<i class="material-icons" style="font-size:18px;">person</i>
<strong>${username}</strong>
${groupBadges}
${isAdmin ? `<span class="muted" style="margin-left:auto;">Admin (full access)</span>`
: `<span class="muted" style="margin-left:auto;">${tf('click_to_edit', 'Click to edit')}</span>`}
</div>
@@ -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);

View File

@@ -90,7 +90,8 @@ export function initializeApp() {
window.currentFolder = last ? last : "root";
const stored = localStorage.getItem('showFoldersInList');
window.showFoldersInList = stored === null ? true : stored === 'true';
// default: false (unchecked)
window.showFoldersInList = stored === 'true';
// Load public site config early (safe subset)
loadAdminConfigFunc();
@@ -99,6 +100,7 @@ export function initializeApp() {
initTagSearch();
/*
// Hook DnD relay from fileList area into upload area
const fileListArea = document.getElementById('fileList');
@@ -146,7 +148,7 @@ export function initializeApp() {
uploadArea.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
}
});
}
}*/
// App subsystems
initDragAndDrop();

View File

@@ -351,30 +351,73 @@ export async function openUserPanel() {
langFs.appendChild(langSel);
content.appendChild(langFs);
// --- Display fieldset: “Show folders above files” ---
// --- Display fieldset: strip + inline folder rows ---
const dispFs = document.createElement('fieldset');
dispFs.style.marginBottom = '15px';
const dispLegend = document.createElement('legend');
dispLegend.textContent = t('display');
dispFs.appendChild(dispLegend);
const dispLabel = document.createElement('label');
dispLabel.style.cursor = 'pointer';
const dispCb = document.createElement('input');
dispCb.type = 'checkbox';
dispCb.id = 'showFoldersInList';
dispCb.style.verticalAlign = 'middle';
const stored = localStorage.getItem('showFoldersInList');
dispCb.checked = stored === null ? true : stored === 'true';
dispLabel.appendChild(dispCb);
dispLabel.append(` ${t('show_folders_above_files')}`);
dispFs.appendChild(dispLabel);
// 1) Show folder strip above list
const stripLabel = document.createElement('label');
stripLabel.style.cursor = 'pointer';
stripLabel.style.display = 'block';
stripLabel.style.marginBottom = '4px';
const stripCb = document.createElement('input');
stripCb.type = 'checkbox';
stripCb.id = 'showFoldersInList';
stripCb.style.verticalAlign = 'middle';
{
const storedStrip = localStorage.getItem('showFoldersInList');
// default: unchecked
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
}
stripLabel.appendChild(stripCb);
stripLabel.append(` ${t('show_folders_above_files')}`);
dispFs.appendChild(stripLabel);
// 2) Show inline folder rows above files in table view
const inlineLabel = document.createElement('label');
inlineLabel.style.cursor = 'pointer';
inlineLabel.style.display = 'block';
const inlineCb = document.createElement('input');
inlineCb.type = 'checkbox';
inlineCb.id = 'showInlineFolders';
inlineCb.style.verticalAlign = 'middle';
{
const storedInline = localStorage.getItem('showInlineFolders');
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
}
inlineLabel.appendChild(inlineCb);
// youll want a string like this in i18n:
// "show_inline_folders": "Show folders inline (above files)"
inlineLabel.append(` ${t('show_inline_folders') || 'Show folders inline (above files)'}`);
dispFs.appendChild(inlineLabel);
content.appendChild(dispFs);
dispCb.addEventListener('change', () => {
window.showFoldersInList = dispCb.checked;
localStorage.setItem('showFoldersInList', dispCb.checked);
// reload the entire file list (and strip) in one go:
loadFileList(window.currentFolder);
// Handlers: toggle + refresh list
stripCb.addEventListener('change', () => {
window.showFoldersInList = stripCb.checked;
localStorage.setItem('showFoldersInList', stripCb.checked);
if (typeof window.loadFileList === 'function') {
window.loadFileList(window.currentFolder || 'root');
}
});
inlineCb.addEventListener('change', () => {
window.showInlineFolders = inlineCb.checked;
localStorage.setItem('showInlineFolders', inlineCb.checked);
if (typeof window.loadFileList === 'function') {
window.loadFileList(window.currentFolder || 'root');
}
});
// wire up imageinput change
@@ -425,6 +468,18 @@ export async function openUserPanel() {
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
// sync display toggles from localStorage
const stripCb = modal.querySelector('#showFoldersInList');
const inlineCb = modal.querySelector('#showInlineFolders');
if (stripCb) {
const storedStrip = localStorage.getItem('showFoldersInList');
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
}
if (inlineCb) {
const storedInline = localStorage.getItem('showInlineFolders');
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
}
}
// show

View File

@@ -160,11 +160,11 @@ export function buildFileTableHeader(sortOrder) {
<thead>
<tr>
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
<th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="size" class="hide-small sortable-col">${t("file_size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploader" class="hide-small hide-medium sortable-col">${t("uploader")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="name" class="sortable-col">${t("name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="modified" class="hide-small sortable-col">${t("modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("created")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="size" class="hide-small sortable-col">${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploader" class="hide-small hide-medium sortable-col">${t("owner")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th>${t("actions")}</th>
</tr>
</thead>

File diff suppressed because it is too large Load Diff

View File

@@ -469,102 +469,118 @@ export function previewFile(fileUrl, fileName) {
return;
}
/* -------------------- VIDEOS -------------------- */
if (isVideo) {
let video = document.createElement("video"); // let so we can rebind
video.controls = true;
video.style.maxWidth = "88vw";
video.style.maxHeight = "88vh";
video.style.objectFit = "contain";
container.appendChild(video);
// Top-right action icons (Material icons, theme-aware)
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
actionWrap.appendChild(markBtnIcon);
actionWrap.appendChild(clearBtnIcon);
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
overlay.mediaType = 'video';
overlay.mediaList = videos;
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); };
const SAVE_INTERVAL_MS = 5000;
let lastSaveAt = 0;
let pending = false;
async function getProgress(nm) {
try {
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
const data = await res.json();
return data && data.state ? data.state : null;
} catch { return null; }
}
async function sendProgress({nm, seconds, duration, completed, clear}) {
try {
pending = true;
const res = await fetch("/api/media/updateProgress.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
});
const data = await res.json();
pending = false;
return data;
} catch (e) { pending = false; console.error(e); return null; }
}
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
function renderStatus(state) {
if (!statusChip) return;
// Completed
if (state && state.completed) {
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
statusChip.style.background = 'rgba(34,197,94,.15)';
statusChip.style.color = '#22c55e';
markBtnIcon.style.display = 'none';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
/* -------------------- VIDEOS -------------------- */
if (isVideo) {
let video = document.createElement("video");
video.controls = true;
video.preload = 'auto'; // hint browser to start fetching quickly
video.style.maxWidth = "88vw";
video.style.maxHeight = "88vh";
video.style.objectFit = "contain";
container.appendChild(video);
// Top-right action icons (Material icons, theme-aware)
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
actionWrap.appendChild(markBtnIcon);
actionWrap.appendChild(clearBtnIcon);
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
overlay.mediaType = 'video';
overlay.mediaList = videos;
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
// Track which file is currently active
let currentName = name;
const setVideoSrc = (nm) => {
currentName = nm;
video.src = buildPreviewUrl(folder, nm);
setTitle(overlay, nm);
};
const SAVE_INTERVAL_MS = 5000;
let lastSaveAt = 0;
let pending = false;
async function getProgress(nm) {
try {
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
const data = await res.json();
return data && data.state ? data.state : null;
} catch { return null; }
}
// In progress
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
statusChip.textContent = `${pct}%`;
statusChip.style.display = 'inline-block';
const dark = document.documentElement.classList.contains('dark-mode');
const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
statusChip.style.color = ORANGE_HEX;
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
async function sendProgress({nm, seconds, duration, completed, clear}) {
try {
pending = true;
const res = await fetch("/api/media/updateProgress.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
});
const data = await res.json();
pending = false;
return data;
} catch (e) {
pending = false;
console.error(e);
return null;
}
}
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
function renderStatus(state) {
if (!statusChip) return;
// Completed
if (state && state.completed) {
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
statusChip.style.background = 'rgba(34,197,94,.15)';
statusChip.style.color = '#22c55e';
markBtnIcon.style.display = 'none';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// In progress
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
statusChip.textContent = `${pct}%`;
statusChip.style.display = 'inline-block';
const dark = document.documentElement.classList.contains('dark-mode');
const ORANGE_HEX = '#ea580c';
statusChip.style.color = ORANGE_HEX;
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// No progress
statusChip.style.display = 'none';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
clearBtnIcon.style.display = 'none';
}
// No progress
statusChip.style.display = 'none';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = 'none';
}
function bindVideoEvents(nm) {
const nv = video.cloneNode(true);
video.replaceWith(nv);
video = nv;
// ---- Event handlers (use currentName instead of rebinding per file) ----
video.addEventListener("loadedmetadata", async () => {
const nm = currentName;
try {
const state = await getProgress(nm);
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
video.currentTime = state.seconds;
const seconds = Math.floor(video.currentTime || 0);
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
setFileProgressBadge(nm, seconds, duration);
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
@@ -577,20 +593,24 @@ export function previewFile(fileUrl, fileName) {
renderStatus(null);
}
});
video.addEventListener("timeupdate", async () => {
const now = Date.now();
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
lastSaveAt = now;
const seconds = Math.floor(video.currentTime || 0);
const nm = currentName;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
sendProgress({ nm, seconds, duration });
setFileProgressBadge(nm, seconds, duration);
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
renderStatus({ seconds, duration, completed: false });
});
video.addEventListener("ended", async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
@@ -598,50 +618,54 @@ export function previewFile(fileUrl, fileName) {
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
});
markBtnIcon.onclick = async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
};
clearBtnIcon.onclick = async () => {
const nm = currentName;
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("progress_cleared") || "Progress cleared");
setFileWatchedBadge(nm, false);
renderStatus(null);
};
}
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const nm = overlay.mediaList[overlay.mediaIndex].name;
setVideoSrc(nm);
bindVideoEvents(nm);
};
if (videos.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
const onKey = (e) => {
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
if (e.key === "ArrowLeft") navigate(-1);
if (e.key === "ArrowRight") navigate(+1);
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const nm = overlay.mediaList[overlay.mediaIndex].name;
setVideoSrc(nm);
renderStatus(null);
};
window.addEventListener("keydown", onKey);
overlay._onKey = onKey;
if (videos.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
const onKey = (e) => {
if (!document.body.contains(overlay)) {
window.removeEventListener("keydown", onKey);
return;
}
if (e.key === "ArrowLeft") navigate(-1);
if (e.key === "ArrowRight") navigate(+1);
};
window.addEventListener("keydown", onKey);
overlay._onKey = onKey;
}
setVideoSrc(name);
renderStatus(null);
overlay.style.display = "flex";
return;
}
setVideoSrc(name);
renderStatus(null);
bindVideoEvents(name);
overlay.style.display = "flex";
return;
}
/* -------------------- AUDIO / OTHER -------------------- */
if (isAudio) {
const audio = document.createElement("audio");

View File

@@ -928,7 +928,6 @@ export function openColorFolderModal(folder) {
border: 1px solid var(--ghost-border, #cfcfcf);
color: var(--ghost-fg, #222);
padding: 6px 12px;
border-radius: 8px;
}
#colorFolderModal .btn-ghost:hover {
background: var(--ghost-hover-bg, #f5f5f5);

View File

@@ -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",
@@ -268,7 +268,7 @@ const translations = {
"columns": "Columns",
"row_height": "Row Height",
"api_docs": "API Docs",
"show_folders_above_files": "Show folders above files",
"show_folders_above_files": "Show folder strip above list",
"display": "Display",
"create_file": "Create File",
"create_new_file": "Create New File",
@@ -330,7 +330,14 @@ const translations = {
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder."
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
"load_more_folders": "Load More Folders",
"show_inline_folders": "Show folders as rows above files",
"name": "Name",
"size": "Size",
"modified": "Modified",
"created": "Created",
"owner": "Owner"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -1,2 +1,2 @@
// generated by CI
window.APP_VERSION = 'v1.9.10';
window.APP_VERSION = 'v1.9.14';

View File

@@ -0,0 +1,166 @@
<?php
// src/controllers/AclAdminController.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
class AclAdminController
{
public function getUserGrants(string $user): array
{
if (!preg_match(REGEX_USER, $user)) {
throw new InvalidArgumentException('Invalid user');
}
$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)) {
$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);
$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}]}');
}
}

View File

@@ -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;

View File

@@ -643,25 +643,137 @@ public function deleteFiles()
} finally { $this->_jsonEnd(); }
}
/**
* Stream a file with proper HTTP Range support so HTML5 video/audio can seek.
*
* @param string $fullPath Absolute filesystem path
* @param string $downloadName Name shown in Content-Disposition
* @param string $mimeType MIME type (from FileModel::getDownloadInfo)
* @param bool $inline true => inline, false => attachment
*/
private function streamFileWithRange(string $fullPath, string $downloadName, string $mimeType, bool $inline): void
{
if (!is_file($fullPath) || !is_readable($fullPath)) {
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'File not found']);
exit;
}
$size = (int)@filesize($fullPath);
$start = 0;
$end = $size > 0 ? $size - 1 : 0;
if ($size < 0) {
$size = 0;
$end = 0;
}
// Close session + disable output buffering for streaming
if (session_status() === PHP_SESSION_ACTIVE) {
@session_write_close();
}
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
}
@ini_set('zlib.output_compression', '0');
@ini_set('output_buffering', 'off');
while (ob_get_level() > 0) {
@ob_end_clean();
}
$disposition = $inline ? 'inline' : 'attachment';
$mime = $mimeType ?: 'application/octet-stream';
header('X-Content-Type-Options: nosniff');
header('Accept-Ranges: bytes');
header("Content-Type: {$mime}");
header("Content-Disposition: {$disposition}; filename=\"" . basename($downloadName) . "\"");
// Handle HTTP Range header (single range)
$length = $size;
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=\s*(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)) {
if ($m[1] !== '') {
$start = (int)$m[1];
}
if ($m[2] !== '') {
$end = (int)$m[2];
}
// clamp to file size
if ($start < 0) $start = 0;
if ($end < $start) $end = $start;
if ($end >= $size) $end = $size - 1;
$length = $end - $start + 1;
http_response_code(206);
header("Content-Range: bytes {$start}-{$end}/{$size}");
header("Content-Length: {$length}");
} else {
// no range => full file
http_response_code(200);
if ($size > 0) {
header("Content-Length: {$size}");
}
}
$fp = @fopen($fullPath, 'rb');
if ($fp === false) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Unable to open file.']);
exit;
}
if ($start > 0) {
@fseek($fp, $start);
}
$bytesToSend = $length;
$chunkSize = 8192;
while ($bytesToSend > 0 && !feof($fp)) {
$readSize = ($bytesToSend > $chunkSize) ? $chunkSize : $bytesToSend;
$buffer = fread($fp, $readSize);
if ($buffer === false) {
break;
}
echo $buffer;
flush();
$bytesToSend -= strlen($buffer);
if (connection_aborted()) {
break;
}
}
fclose($fp);
exit;
}
public function downloadFile()
{
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
header('Content-Type: application/json');
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Unauthorized"]);
exit;
}
$file = isset($_GET['file']) ? basename($_GET['file']) : '';
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
$file = isset($_GET['file']) ? basename((string)$_GET['file']) : '';
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
$inlineParam = isset($_GET['inline']) && (string)$_GET['inline'] === '1';
if (!preg_match(REGEX_FILE_NAME, $file)) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Invalid file name."]);
exit;
}
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
@@ -681,6 +793,7 @@ public function deleteFiles()
if (!$fullView && !$ownGrant) {
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Forbidden: no view access to this folder."]);
exit;
}
@@ -690,6 +803,7 @@ public function deleteFiles()
$meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) {
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
exit;
}
@@ -697,25 +811,25 @@ public function deleteFiles()
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
if (isset($downloadInfo['error'])) {
http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400);
http_response_code(in_array($downloadInfo['error'], ["File not found.", "Access forbidden."]) ? 404 : 400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => $downloadInfo['error']]);
exit;
}
$realFilePath = $downloadInfo['filePath'];
$mimeType = $downloadInfo['mimeType'];
header("Content-Type: " . $mimeType);
// Decide inline vs attachment:
// - if ?inline=1 => always inline (used by filePreview.js)
// - else keep your old behavior: images inline, everything else attachment
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
$inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico'];
if (in_array($ext, $inlineImageTypes, true)) {
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
} else {
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
}
header('Content-Length: ' . filesize($realFilePath));
readfile($realFilePath);
exit;
$inline = $inlineParam || in_array($ext, $inlineImageTypes, true);
// Stream with proper Range support for video/audio seeking
$this->streamFileWithRange($realFilePath, basename($realFilePath), $mimeType, $inline);
}
public function zipStatus()

View File

@@ -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');
}
}
}

View File

@@ -11,87 +11,111 @@ class FolderModel
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
* ============================================================ */
public static function countVisible(string $folder, string $user, array $perms): array
{
$folder = ACL::normalizeFolder($folder);
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
$canViewFolder = ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $folder)
|| ACL::canReadOwn($user, $perms, $folder);
if (!$canViewFolder) return ['folders' => 0, 'files' => 0];
$base = realpath((string)UPLOAD_DIR);
if ($base === false) return ['folders' => 0, 'files' => 0];
// Resolve target dir + ACL-relative prefix
if ($folder === 'root') {
$dir = $base;
$relPrefix = '';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!self::isSafeSegment($seg)) return ['folders' => 0, 'files' => 0];
}
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dir = self::safeReal($base, $guess);
if ($dir === null || !is_dir($dir)) return ['folders' => 0, 'files' => 0];
$relPrefix = implode('/', $parts);
}
// Ignore lists (expandable)
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics'];
$entries = @scandir($dir);
if ($entries === false) return ['folders' => 0, 'files' => 0];
$hasChildFolder = false;
$hasFile = false;
// Cap scanning to avoid pathological dirs
$MAX_SCAN = 4000;
$scanned = 0;
foreach ($entries as $name) {
if (++$scanned > $MAX_SCAN) break;
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue;
$abs = $dir . DIRECTORY_SEPARATOR . $name;
if (@is_dir($abs)) {
// Symlink defense on children
if (@is_link($abs)) {
$safe = self::safeReal($base, $abs);
if ($safe === null || !is_dir($safe)) continue;
}
// Only count child dirs the user can view (admin/read/read_own)
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
if (
ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $childRel)
|| ACL::canReadOwn($user, $perms, $childRel)
) {
$hasChildFolder = true;
}
} elseif (@is_file($abs)) {
// Any file present is enough for the "files" flag once the folder itself is viewable
$hasFile = true;
}
if ($hasChildFolder && $hasFile) break; // early exit
}
return [
'folders' => $hasChildFolder ? 1 : 0,
'files' => $hasFile ? 1 : 0,
];
}
public static function countVisible(string $folder, string $user, array $perms): array
{
$folder = ACL::normalizeFolder($folder);
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
$canViewFolder = ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $folder)
|| ACL::canReadOwn($user, $perms, $folder);
if (!$canViewFolder) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
// NEW: distinguish full read vs own-only for this folder
$hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder);
// if !$hasFullRead but $canViewFolder is true, theyre effectively "view own" only
$base = realpath((string)UPLOAD_DIR);
if ($base === false) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
// Resolve target dir + ACL-relative prefix
if ($folder === 'root') {
$dir = $base;
$relPrefix = '';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!self::isSafeSegment($seg)) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
}
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dir = self::safeReal($base, $guess);
if ($dir === null || !is_dir($dir)) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
$relPrefix = implode('/', $parts);
}
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics'];
$entries = @scandir($dir);
if ($entries === false) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
$folderCount = 0;
$fileCount = 0;
$totalBytes = 0;
$MAX_SCAN = 4000;
$scanned = 0;
foreach ($entries as $name) {
if (++$scanned > $MAX_SCAN) {
break;
}
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue;
$abs = $dir . DIRECTORY_SEPARATOR . $name;
if (@is_dir($abs)) {
if (@is_link($abs)) {
$safe = self::safeReal($base, $abs);
if ($safe === null || !is_dir($safe)) {
continue;
}
}
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
if (
ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $childRel)
|| ACL::canReadOwn($user, $perms, $childRel)
) {
$folderCount++;
}
} elseif (@is_file($abs)) {
// Only count files if the user has full read on *this* folder.
// If theyre view_own-only here, dont leak or mis-report counts.
if (!$hasFullRead) {
continue;
}
$fileCount++;
$sz = @filesize($abs);
if (is_int($sz) && $sz > 0) {
$totalBytes += $sz;
}
}
}
return [
'folders' => $folderCount,
'files' => $fileCount,
'bytes' => $totalBytes,
];
}
/* Helpers (private) */
private static function isSafeSegment(string $name): bool

View File

@@ -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