Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3589a1c232 | ||
|
|
1b4a93b060 | ||
|
|
bf077b142b | ||
|
|
f78e2f3f16 | ||
|
|
08a84419f0 | ||
|
|
49d3588322 |
68
CHANGELOG.md
68
CHANGELOG.md
@@ -1,5 +1,73 @@
|
||||
# Changelog
|
||||
|
||||
## 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 user’s ACL:
|
||||
- Show which caps are coming from groups, lock those checkboxes, and update tooltips.
|
||||
- Show group membership badges in the user permissions list.
|
||||
- Add a collapsed “Groups” section at the top of the permissions screen to preview group ACLs (read-only).
|
||||
|
||||
- Misc:
|
||||
- Bump PRO_LATEST_BUNDLE_VERSION hint in adminPanel.js to v1.0.1.
|
||||
- Tweak modal border-radius styling to include the new userGroups and groupAcl modals.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/18/2025 (v1.9.11)
|
||||
|
||||
release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68)
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
32
public/api/pro/groups/list.php
Normal file
32
public/api/pro/groups/list.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
51
public/api/pro/groups/save.php
Normal file
51
public/api/pro/groups/save.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
28
public/api/pro/uploadBrandLogo.php
Normal file
28
public/api/pro/uploadBrandLogo.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1977,4 +1992,103 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
|
||||
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}
|
||||
@@ -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;">×</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 we’re just re-rendering after local edits, don’t clobber cache
|
||||
groups = __groupsCache;
|
||||
} else {
|
||||
// Initial load, or explicit refresh – pull from server
|
||||
groups = await fetchAllGroups();
|
||||
__groupsCache = groups || {};
|
||||
}
|
||||
|
||||
const usernames = users
|
||||
.map(u => String(u.username || '').trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const groupNames = Object.keys(__groupsCache).sort((a, b) => a.localeCompare(b));
|
||||
if (!groupNames.length) {
|
||||
body.innerHTML = `<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, '"')}"
|
||||
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;">×</span>
|
||||
|
||||
<h3 id="groupAclTitle">Group folder access</h3>
|
||||
<div class="muted" style="margin:-4px 0 10px;">
|
||||
Group grants are merged with each member’s 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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
// you’ll 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);
|
||||
// re‐load 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 image‐input 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -240,16 +240,29 @@ window.addEventListener('resize', () => {
|
||||
if (strip) applyFolderStripLayout(strip);
|
||||
});
|
||||
|
||||
// Listen once: update strip + tree when folder color changes
|
||||
// Listen once: update strip + tree + inline rows when folder color changes
|
||||
window.addEventListener('folderColorChanged', (e) => {
|
||||
const { folder } = e.detail || {};
|
||||
if (!folder) return;
|
||||
|
||||
// Update the strip (if that folder is currently shown)
|
||||
// 1) Update the strip (if that folder is currently shown)
|
||||
repaintStripIcon(folder);
|
||||
|
||||
// And refresh the tree icon too (existing function)
|
||||
// 2) Refresh the tree icon (existing function)
|
||||
try { refreshFolderIcon(folder); } catch { }
|
||||
|
||||
// 3) Repaint any inline folder rows in the file table
|
||||
try {
|
||||
const safeFolder = CSS.escape(folder);
|
||||
document
|
||||
.querySelectorAll(`#fileList tr.folder-row[data-folder="${safeFolder}"]`)
|
||||
.forEach(row => {
|
||||
// reuse the same helper we used when injecting inline rows
|
||||
attachStripIconAsync(row, folder, 28);
|
||||
});
|
||||
} catch {
|
||||
// CSS.escape might not exist on very old browsers; fail silently
|
||||
}
|
||||
});
|
||||
|
||||
// Hide "Edit" for files >10 MiB
|
||||
@@ -259,11 +272,25 @@ const MAX_EDIT_BYTES = 10 * 1024 * 1024;
|
||||
let __fileListReqSeq = 0;
|
||||
|
||||
window.itemsPerPage = parseInt(
|
||||
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
|
||||
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '50',
|
||||
10
|
||||
);
|
||||
window.currentPage = window.currentPage || 1;
|
||||
window.viewMode = localStorage.getItem("viewMode") || "table";
|
||||
window.currentSubfolders = window.currentSubfolders || [];
|
||||
|
||||
// Default folder display settings from localStorage
|
||||
try {
|
||||
const storedStrip = localStorage.getItem('showFoldersInList');
|
||||
const storedInline = localStorage.getItem('showInlineFolders');
|
||||
|
||||
window.showFoldersInList = storedStrip === null ? true : storedStrip === 'true';
|
||||
window.showInlineFolders = storedInline === null ? true : storedInline === 'true';
|
||||
} catch {
|
||||
// if localStorage blows up, fall back to both enabled
|
||||
window.showFoldersInList = true;
|
||||
window.showInlineFolders = true;
|
||||
}
|
||||
|
||||
// Global flag for advanced search mode.
|
||||
window.advancedSearchEnabled = false;
|
||||
@@ -387,7 +414,6 @@ function attachStripIconAsync(hostEl, fullPath, size = 28) {
|
||||
const back = _lighten(hex, 14);
|
||||
const stroke = _darken(hex, 22);
|
||||
|
||||
// apply vars on the tile (or icon span)
|
||||
hostEl.style.setProperty('--filr-folder-front', front);
|
||||
hostEl.style.setProperty('--filr-folder-back', back);
|
||||
hostEl.style.setProperty('--filr-folder-stroke', stroke);
|
||||
@@ -395,15 +421,26 @@ function attachStripIconAsync(hostEl, fullPath, size = 28) {
|
||||
const iconSpan = hostEl.querySelector('.folder-svg');
|
||||
if (!iconSpan) return;
|
||||
|
||||
// 1) initial "empty" icon
|
||||
iconSpan.dataset.kind = 'empty';
|
||||
iconSpan.innerHTML = folderSVG('empty'); // size is baked into viewBox; add a size arg if you prefer
|
||||
iconSpan.innerHTML = folderSVG('empty');
|
||||
|
||||
// make sure this brand-new SVG is sized correctly
|
||||
try { syncFolderIconSizeToRowHeight(); } catch {}
|
||||
|
||||
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(fullPath)}&t=${Date.now()}`;
|
||||
_fetchJSONWithTimeout(url, 2500).then(({ folders = 0, files = 0 }) => {
|
||||
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
|
||||
iconSpan.dataset.kind = 'paper';
|
||||
iconSpan.innerHTML = folderSVG('paper');
|
||||
}
|
||||
}).catch(() => { });
|
||||
_fetchJSONWithTimeout(url, 2500)
|
||||
.then(({ folders = 0, files = 0 }) => {
|
||||
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
|
||||
// 2) swap to "paper" icon
|
||||
iconSpan.dataset.kind = 'paper';
|
||||
iconSpan.innerHTML = folderSVG('paper');
|
||||
|
||||
// re-apply sizing to this new SVG too
|
||||
try { syncFolderIconSizeToRowHeight(); } catch {}
|
||||
}
|
||||
})
|
||||
.catch(() => { /* ignore */ });
|
||||
}
|
||||
|
||||
/* -----------------------------
|
||||
@@ -426,6 +463,56 @@ async function safeJson(res) {
|
||||
}
|
||||
return body ?? {};
|
||||
}
|
||||
|
||||
// --- Folder capabilities + owner cache ----------------------
|
||||
const _folderCapsCache = new Map();
|
||||
|
||||
async function fetchFolderCaps(folder) {
|
||||
if (!folder) return null;
|
||||
if (_folderCapsCache.has(folder)) {
|
||||
return _folderCapsCache.get(folder);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const data = await safeJson(res);
|
||||
_folderCapsCache.set(folder, data || null);
|
||||
|
||||
if (data && (data.owner || data.user)) {
|
||||
_folderOwnerCache.set(folder, data.owner || data.user || "");
|
||||
}
|
||||
return data || null;
|
||||
} catch {
|
||||
_folderCapsCache.set(folder, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Folder owner cache + helper ----------------------
|
||||
const _folderOwnerCache = new Map();
|
||||
|
||||
async function fetchFolderOwner(folder) {
|
||||
if (!folder) return "";
|
||||
if (_folderOwnerCache.has(folder)) {
|
||||
return _folderOwnerCache.get(folder);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const data = await safeJson(res);
|
||||
const owner = data && (data.owner || data.user || "");
|
||||
_folderOwnerCache.set(folder, owner || "");
|
||||
return owner || "";
|
||||
} catch {
|
||||
_folderOwnerCache.set(folder, "");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
// ---- Viewed badges (table + gallery) ----
|
||||
// ---------- Badge factory (center text vertically) ----------
|
||||
function makeBadge(state) {
|
||||
@@ -917,6 +1004,13 @@ export async function loadFileList(folderParam) {
|
||||
document.documentElement.style.setProperty("--file-row-height", v + "px");
|
||||
localStorage.setItem("rowHeight", v);
|
||||
rowValue.textContent = v + "px";
|
||||
// mark compact mode for very low heights
|
||||
if (v <= 32) {
|
||||
document.documentElement.setAttribute('data-row-compact', '1');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-row-compact');
|
||||
}
|
||||
syncFolderIconSizeToRowHeight();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -966,6 +1060,9 @@ export async function loadFileList(folderParam) {
|
||||
return !hidden.has(lower) && !lower.startsWith("resumable_");
|
||||
});
|
||||
|
||||
// Expose for inline folder rows in table view
|
||||
window.currentSubfolders = subfolders;
|
||||
|
||||
let strip = document.getElementById("folderStripContainer");
|
||||
if (!strip) {
|
||||
strip = document.createElement("div");
|
||||
@@ -976,6 +1073,11 @@ export async function loadFileList(folderParam) {
|
||||
|
||||
// NEW: paged + responsive strip
|
||||
renderFolderStripPaged(strip, subfolders);
|
||||
|
||||
// Re-render table view once folders are known so they appear inline above files
|
||||
if (window.viewMode === "table" && reqId === __fileListReqSeq) {
|
||||
renderFileTable(folder);
|
||||
}
|
||||
} catch {
|
||||
// ignore folder errors; rows already rendered
|
||||
}
|
||||
@@ -1000,24 +1102,456 @@ export async function loadFileList(folderParam) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function injectInlineFolderRows(fileListContent, folder, pageSubfolders) {
|
||||
const table = fileListContent.querySelector('table.filr-table');
|
||||
|
||||
// Use the paged subfolders if provided, otherwise fall back to all
|
||||
const subfolders = Array.isArray(pageSubfolders) && pageSubfolders.length
|
||||
? pageSubfolders
|
||||
: (Array.isArray(window.currentSubfolders) ? window.currentSubfolders : []);
|
||||
|
||||
if (!table || !subfolders.length) return;
|
||||
|
||||
const thead = table.tHead;
|
||||
const tbody = table.tBodies && table.tBodies[0];
|
||||
if (!thead || !tbody) return;
|
||||
|
||||
const headerRow = thead.rows[0];
|
||||
if (!headerRow) return;
|
||||
|
||||
const headerCells = Array.from(headerRow.cells);
|
||||
const colCount = headerCells.length;
|
||||
|
||||
// --- Column indices -------------------------------------------------------
|
||||
let checkboxIdx = headerCells.findIndex(th =>
|
||||
th.classList.contains("checkbox-col") ||
|
||||
th.querySelector('input[type="checkbox"]')
|
||||
);
|
||||
|
||||
let nameIdx = headerCells.findIndex(th =>
|
||||
(th.dataset && th.dataset.column === "name") ||
|
||||
/\bname\b/i.test((th.textContent || "").trim())
|
||||
);
|
||||
if (nameIdx < 0) {
|
||||
nameIdx = Math.min(1, colCount - 1); // fallback to 2nd col
|
||||
}
|
||||
|
||||
let sizeIdx = headerCells.findIndex(th =>
|
||||
(th.dataset && (th.dataset.column === "size" || th.dataset.column === "filesize")) ||
|
||||
/\bsize\b/i.test((th.textContent || "").trim())
|
||||
);
|
||||
if (sizeIdx < 0) sizeIdx = -1;
|
||||
|
||||
let uploaderIdx = headerCells.findIndex(th =>
|
||||
(th.dataset && th.dataset.column === "uploader") ||
|
||||
/\buploader\b/i.test((th.textContent || "").trim())
|
||||
);
|
||||
if (uploaderIdx < 0) uploaderIdx = -1;
|
||||
|
||||
let actionsIdx = headerCells.findIndex(th =>
|
||||
(th.dataset && th.dataset.column === "actions") ||
|
||||
/\bactions?\b/i.test((th.textContent || "").trim()) ||
|
||||
/\bactions?-col\b/i.test(th.className || "")
|
||||
);
|
||||
if (actionsIdx < 0) actionsIdx = -1;
|
||||
|
||||
// Remove any previous folder rows
|
||||
tbody.querySelectorAll("tr.folder-row").forEach(tr => tr.remove());
|
||||
|
||||
|
||||
|
||||
const firstDataRow = tbody.firstElementChild;
|
||||
|
||||
subfolders.forEach(sf => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.classList.add("folder-row");
|
||||
tr.dataset.folder = sf.full;
|
||||
|
||||
for (let i = 0; i < colCount; i++) {
|
||||
const td = document.createElement("td");
|
||||
|
||||
// *** copy header classes so responsive breakpoints match file rows ***
|
||||
// but strip Bootstrap margin helpers (ml-2 / mx-2) so we don't get a big gap
|
||||
const headerClass = headerCells[i] && headerCells[i].className;
|
||||
if (headerClass) {
|
||||
td.className = headerClass;
|
||||
td.classList.remove("ml-2", "mx-2");
|
||||
}
|
||||
|
||||
// 1) icon / checkbox column
|
||||
if (i === checkboxIdx) {
|
||||
td.classList.add("folder-icon-cell");
|
||||
td.style.textAlign = "left";
|
||||
td.style.verticalAlign = "middle";
|
||||
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "folder-svg folder-row-icon";
|
||||
td.appendChild(iconSpan);
|
||||
|
||||
// 2) name column
|
||||
} else if (i === nameIdx) {
|
||||
td.classList.add("name-cell", "file-name-cell", "folder-name-cell");
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "folder-row-inner";
|
||||
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "folder-row-name";
|
||||
nameSpan.textContent = sf.name || sf.full;
|
||||
|
||||
const metaSpan = document.createElement("span");
|
||||
metaSpan.className = "folder-row-meta";
|
||||
metaSpan.textContent = ""; // "(15 folders, 19 files)" later
|
||||
|
||||
wrap.appendChild(nameSpan);
|
||||
wrap.appendChild(metaSpan);
|
||||
td.appendChild(wrap);
|
||||
|
||||
// 3) size column
|
||||
} else if (i === sizeIdx) {
|
||||
td.classList.add("folder-size-cell");
|
||||
td.textContent = "…"; // placeholder until we load stats
|
||||
|
||||
// 4) uploader / owner column
|
||||
} else if (i === uploaderIdx) {
|
||||
td.classList.add("uploader-cell", "folder-uploader-cell");
|
||||
td.textContent = ""; // filled asynchronously with owner
|
||||
|
||||
// 5) actions column
|
||||
} else if (i === actionsIdx) {
|
||||
td.classList.add("folder-actions-cell");
|
||||
|
||||
const group = document.createElement("div");
|
||||
group.className = "btn-group btn-group-sm folder-actions-group";
|
||||
group.setAttribute("role", "group");
|
||||
group.setAttribute("aria-label", "File actions");
|
||||
|
||||
const makeActionBtn = (iconName, titleKey, btnClass, actionKey, handler) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
|
||||
// base classes – same size as file actions
|
||||
btn.className = `btn ${btnClass} py-1`;
|
||||
|
||||
// kill any Bootstrap margin helpers that got passed in
|
||||
btn.classList.remove("ml-2", "mx-2");
|
||||
|
||||
btn.setAttribute("data-folder-action", actionKey);
|
||||
btn.setAttribute("data-i18n-title", titleKey);
|
||||
btn.title = t(titleKey);
|
||||
|
||||
const icon = document.createElement("i");
|
||||
icon.className = "material-icons";
|
||||
icon.textContent = iconName;
|
||||
btn.appendChild(icon);
|
||||
|
||||
btn.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
window.currentFolder = sf.full;
|
||||
try { localStorage.setItem("lastOpenedFolder", sf.full); } catch {}
|
||||
handler();
|
||||
});
|
||||
|
||||
// start disabled; caps logic will enable
|
||||
btn.disabled = true;
|
||||
btn.style.pointerEvents = "none";
|
||||
btn.style.opacity = "0.5";
|
||||
|
||||
group.appendChild(btn);
|
||||
};
|
||||
|
||||
makeActionBtn("drive_file_move", "move_folder", "btn-warning folder-move-btn", "move", () => openMoveFolderUI());
|
||||
makeActionBtn("palette", "color_folder", "btn-color-folder","color", () => openColorFolderModal(sf.full));
|
||||
makeActionBtn("drive_file_rename_outline", "rename_folder", "btn-warning folder-rename-btn", "rename", () => openRenameFolderModal());
|
||||
makeActionBtn("share", "share_folder", "btn-secondary", "share", () => openFolderShareModal(sf.full));
|
||||
|
||||
td.appendChild(group);
|
||||
}
|
||||
|
||||
// IMPORTANT: always append the cell, no matter which column we're in
|
||||
tr.appendChild(td);
|
||||
}
|
||||
|
||||
// click → navigate, same as before
|
||||
tr.addEventListener("click", e => {
|
||||
if (e.button !== 0) return;
|
||||
const dest = sf.full;
|
||||
if (!dest) return;
|
||||
|
||||
window.currentFolder = dest;
|
||||
try { localStorage.setItem("lastOpenedFolder", dest); } catch { }
|
||||
|
||||
updateBreadcrumbTitle(dest);
|
||||
|
||||
document.querySelectorAll(".folder-option.selected")
|
||||
.forEach(o => o.classList.remove("selected"));
|
||||
const treeNode = document.querySelector(
|
||||
`.folder-option[data-folder="${CSS.escape(dest)}"]`
|
||||
);
|
||||
if (treeNode) treeNode.classList.add("selected");
|
||||
|
||||
const strip = document.getElementById("folderStripContainer");
|
||||
if (strip) {
|
||||
strip.querySelectorAll(".folder-item.selected")
|
||||
.forEach(i => i.classList.remove("selected"));
|
||||
const stripItem = strip.querySelector(
|
||||
`.folder-item[data-folder="${CSS.escape(dest)}"]`
|
||||
);
|
||||
if (stripItem) stripItem.classList.add("selected");
|
||||
}
|
||||
|
||||
loadFileList(dest);
|
||||
});
|
||||
|
||||
|
||||
// DnD + context menu – keep existing logic, but also add a visual highlight
|
||||
tr.addEventListener("dragover", e => {
|
||||
folderDragOverHandler(e);
|
||||
tr.classList.add("folder-row-droptarget");
|
||||
});
|
||||
|
||||
tr.addEventListener("dragleave", e => {
|
||||
folderDragLeaveHandler(e);
|
||||
tr.classList.remove("folder-row-droptarget");
|
||||
});
|
||||
|
||||
tr.addEventListener("drop", e => {
|
||||
folderDropHandler(e);
|
||||
tr.classList.remove("folder-row-droptarget");
|
||||
});
|
||||
|
||||
tr.addEventListener("contextmenu", e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const dest = sf.full;
|
||||
if (!dest) return;
|
||||
|
||||
window.currentFolder = dest;
|
||||
try { localStorage.setItem("lastOpenedFolder", dest); } catch { }
|
||||
|
||||
const menuItems = [
|
||||
{ label: t("create_folder"), action: () => document.getElementById("createFolderModal").style.display = "block" },
|
||||
{ label: t("move_folder"), action: () => openMoveFolderUI() },
|
||||
{ label: t("rename_folder"), action: () => openRenameFolderModal() },
|
||||
{ label: t("color_folder"), action: () => openColorFolderModal(dest) },
|
||||
{ label: t("folder_share"), action: () => openFolderShareModal(dest) },
|
||||
{ label: t("delete_folder"), action: () => openDeleteFolderModal() }
|
||||
];
|
||||
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||
});
|
||||
|
||||
// insert row above first file row
|
||||
tbody.insertBefore(tr, firstDataRow || null);
|
||||
|
||||
// ----- ICON: color + alignment (size is driven by row height) -----
|
||||
attachStripIconAsync(tr, sf.full);
|
||||
const iconSpan = tr.querySelector(".folder-row-icon");
|
||||
if (iconSpan) {
|
||||
iconSpan.style.display = "inline-flex";
|
||||
iconSpan.style.alignItems = "center";
|
||||
iconSpan.style.justifyContent = "flex-start";
|
||||
iconSpan.style.marginLeft = "0px"; // small left nudge
|
||||
iconSpan.style.marginTop = "0px"; // small down nudge
|
||||
}
|
||||
|
||||
// ----- FOLDER STATS + OWNER + CAPS (keep your existing code below here) -----
|
||||
const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1;
|
||||
const nameCellIndex = (nameIdx >= 0 && nameIdx < tr.cells.length) ? nameIdx : -1;
|
||||
|
||||
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(sf.full)}&t=${Date.now()}`;
|
||||
_fetchJSONWithTimeout(url, 2500).then(stats => {
|
||||
if (!stats) return;
|
||||
|
||||
const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
|
||||
const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
|
||||
const bytes = Number.isFinite(stats.bytes)
|
||||
? stats.bytes
|
||||
: (Number.isFinite(stats.sizeBytes) ? stats.sizeBytes : null);
|
||||
|
||||
let pieces = [];
|
||||
if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
|
||||
if (filesCount) pieces.push(`${filesCount} file${filesCount === 1 ? "" : "s"}`);
|
||||
if (!pieces.length) pieces.push("0 items");
|
||||
const countLabel = pieces.join(", ");
|
||||
|
||||
if (nameCellIndex >= 0) {
|
||||
const nameCell = tr.cells[nameCellIndex];
|
||||
if (nameCell) {
|
||||
const metaSpan = nameCell.querySelector(".folder-row-meta");
|
||||
if (metaSpan) metaSpan.textContent = ` (${countLabel})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (sizeCellIndex >= 0) {
|
||||
const sizeCell = tr.cells[sizeCellIndex];
|
||||
if (sizeCell) {
|
||||
let sizeLabel = "—";
|
||||
if (bytes != null && bytes >= 0) {
|
||||
sizeLabel = formatSize(bytes);
|
||||
}
|
||||
sizeCell.textContent = sizeLabel;
|
||||
sizeCell.title = `${countLabel}${bytes != null && bytes >= 0 ? " • " + sizeLabel : ""}`;
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
if (sizeCellIndex >= 0) {
|
||||
const sizeCell = tr.cells[sizeCellIndex];
|
||||
if (sizeCell && !sizeCell.textContent) sizeCell.textContent = "—";
|
||||
}
|
||||
});
|
||||
|
||||
// OWNER + action permissions
|
||||
if (uploaderIdx >= 0 || actionsIdx >= 0) {
|
||||
fetchFolderCaps(sf.full).then(caps => {
|
||||
if (!caps || !document.body.contains(tr)) return;
|
||||
|
||||
if (uploaderIdx >= 0 && uploaderIdx < tr.cells.length) {
|
||||
const uploaderCell = tr.cells[uploaderIdx];
|
||||
if (uploaderCell) {
|
||||
const owner = caps.owner || caps.user || "";
|
||||
uploaderCell.textContent = owner || "";
|
||||
}
|
||||
}
|
||||
|
||||
if (actionsIdx >= 0 && actionsIdx < tr.cells.length) {
|
||||
const actCell = tr.cells[actionsIdx];
|
||||
if (!actCell) return;
|
||||
|
||||
actCell.querySelectorAll('button[data-folder-action]').forEach(btn => {
|
||||
const action = btn.getAttribute('data-folder-action');
|
||||
let enabled = false;
|
||||
switch (action) {
|
||||
case "move":
|
||||
enabled = !!caps.canMoveFolder;
|
||||
break;
|
||||
case "color":
|
||||
enabled = !!caps.canRename; // same gate as tree “color” button
|
||||
break;
|
||||
case "rename":
|
||||
enabled = !!caps.canRename;
|
||||
break;
|
||||
case "share":
|
||||
enabled = !!caps.canShareFolder;
|
||||
break;
|
||||
}
|
||||
if (enabled === undefined) {
|
||||
enabled = true; // fallback so admin still gets buttons even if a flag is missing
|
||||
}
|
||||
if (enabled) {
|
||||
btn.disabled = false;
|
||||
btn.style.pointerEvents = "";
|
||||
btn.style.opacity = "";
|
||||
} else {
|
||||
btn.disabled = true;
|
||||
btn.style.pointerEvents = "none";
|
||||
btn.style.opacity = "0.5";
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(() => { /* ignore */ });
|
||||
}
|
||||
});
|
||||
syncFolderIconSizeToRowHeight();
|
||||
}
|
||||
function syncFolderIconSizeToRowHeight() {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const raw = cs.getPropertyValue('--file-row-height') || '48px';
|
||||
const rowH = parseInt(raw, 10) || 60;
|
||||
|
||||
const FUDGE = 5;
|
||||
const MAX_GROWTH_ROW = 44; // after this, stop growing the icon
|
||||
|
||||
const BASE_ROW_FOR_OFFSET = 40; // where icon looks centered
|
||||
const OFFSET_FACTOR = 0.25;
|
||||
|
||||
// cap growth for size, like you already do
|
||||
const effectiveRow = Math.min(rowH, MAX_GROWTH_ROW);
|
||||
|
||||
const boxSize = Math.max(25, Math.min(35, effectiveRow - 20 + FUDGE));
|
||||
const scale = 1.20;
|
||||
|
||||
// use your existing offset curve
|
||||
const clampedForOffset = Math.max(30, Math.min(60, rowH));
|
||||
let offsetY = (clampedForOffset - BASE_ROW_FOR_OFFSET) * OFFSET_FACTOR;
|
||||
|
||||
// 30–44: untouched (you said this range is perfect)
|
||||
// 45–60: same curve, but shifted up slightly
|
||||
if (rowH > 53) {
|
||||
offsetY -= 3;
|
||||
}
|
||||
|
||||
document.querySelectorAll('#fileList .folder-row-icon').forEach(iconSpan => {
|
||||
iconSpan.style.width = boxSize + 'px';
|
||||
iconSpan.style.height = boxSize + 'px';
|
||||
iconSpan.style.overflow = 'visible';
|
||||
|
||||
const svg = iconSpan.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
svg.setAttribute('width', String(boxSize));
|
||||
svg.setAttribute('height', String(boxSize));
|
||||
svg.style.transformOrigin = 'left center';
|
||||
svg.style.transform = `translateY(${offsetY}px) scale(${scale})`;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Render table view
|
||||
*/
|
||||
export function renderFileTable(folder, container, subfolders) {
|
||||
const fileListContent = container || document.getElementById("fileList");
|
||||
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
||||
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
|
||||
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "50", 10);
|
||||
let currentPage = window.currentPage || 1;
|
||||
|
||||
// Files (filtered by search)
|
||||
const filteredFiles = searchFiles(searchTerm);
|
||||
|
||||
const totalFiles = filteredFiles.length;
|
||||
const totalPages = Math.ceil(totalFiles / itemsPerPageSetting);
|
||||
// Inline folders: sort once (Explorer-style A→Z)
|
||||
const allSubfolders = Array.isArray(window.currentSubfolders)
|
||||
? window.currentSubfolders
|
||||
: [];
|
||||
const subfoldersSorted = [...allSubfolders].sort((a, b) =>
|
||||
(a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" })
|
||||
);
|
||||
|
||||
const totalFiles = filteredFiles.length;
|
||||
const totalFolders = subfoldersSorted.length;
|
||||
const totalRows = totalFiles + totalFolders;
|
||||
const hasFolders = totalFolders > 0;
|
||||
|
||||
// Pagination is now over (folders + files)
|
||||
const totalPages = totalRows > 0
|
||||
? Math.ceil(totalRows / itemsPerPageSetting)
|
||||
: 1;
|
||||
|
||||
if (currentPage > totalPages) {
|
||||
currentPage = totalPages > 0 ? totalPages : 1;
|
||||
currentPage = totalPages;
|
||||
window.currentPage = currentPage;
|
||||
}
|
||||
|
||||
const startRow = (currentPage - 1) * itemsPerPageSetting;
|
||||
const endRow = Math.min(startRow + itemsPerPageSetting, totalRows);
|
||||
|
||||
// Figure out which folders + files belong to THIS page
|
||||
const pageFolders = [];
|
||||
const pageFiles = [];
|
||||
|
||||
for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) {
|
||||
if (rowIndex < totalFolders) {
|
||||
pageFolders.push(subfoldersSorted[rowIndex]);
|
||||
} else {
|
||||
const fileIdx = rowIndex - totalFolders;
|
||||
const file = filteredFiles[fileIdx];
|
||||
if (file) pageFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Stable id per file row on this page
|
||||
const rowIdFor = (file, idx) =>
|
||||
`${encodeURIComponent(file.name)}-p${currentPage}-${idx}`;
|
||||
|
||||
// We pass a harmless "base" string to keep buildFileTableRow happy,
|
||||
// then we will FIX the preview/thumbnail URLs to the API below.
|
||||
const fakeBase = "#/";
|
||||
@@ -1040,19 +1574,16 @@ export function renderFileTable(folder, container, subfolders) {
|
||||
return `<table class="filr-table"${attrs}>`;
|
||||
});
|
||||
|
||||
const startIndex = (currentPage - 1) * itemsPerPageSetting;
|
||||
const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles);
|
||||
let rowsHTML = "<tbody>";
|
||||
if (totalFiles > 0) {
|
||||
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
|
||||
// Build row with a neutral base, then correct the links/preview below.
|
||||
// Give the row an ID so we can patch attributes safely
|
||||
const idSafe = encodeURIComponent(file.name) + "-" + (startIndex + idx);
|
||||
let rowsHTML = "<tbody>";
|
||||
|
||||
if (pageFiles.length > 0) {
|
||||
pageFiles.forEach((file, idx) => {
|
||||
const rowKey = rowIdFor(file, idx);
|
||||
let rowHTML = buildFileTableRow(file, fakeBase);
|
||||
|
||||
// add row id + data-file-name, and ensure the name cell also has "name-cell"
|
||||
rowHTML = rowHTML
|
||||
.replace("<tr", `<tr id="file-row-${idSafe}" data-file-name="${escapeHTML(file.name)}"`)
|
||||
.replace("<tr", `<tr id="file-row-${rowKey}" data-file-name="${escapeHTML(file.name)}"`)
|
||||
.replace('class="file-name-cell"', 'class="file-name-cell name-cell"');
|
||||
|
||||
let tagBadgesHTML = "";
|
||||
@@ -1063,54 +1594,63 @@ export function renderFileTable(folder, container, subfolders) {
|
||||
});
|
||||
tagBadgesHTML += "</div>";
|
||||
}
|
||||
|
||||
rowsHTML += rowHTML.replace(
|
||||
/(<td\s+class="[^"]*\bfile-name-cell\b[^"]*">)([\s\S]*?)(<\/td>)/,
|
||||
(m, open, inner, close) => {
|
||||
// keep the original filename content, then add your tag badges, then close
|
||||
return `${open}<span class="filename-text">${inner}</span>${tagBadgesHTML}${close}`;
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`;
|
||||
} else if (!hasFolders && totalFiles === 0) {
|
||||
// Only show "No files found" if there are no folders either
|
||||
rowsHTML += `<tr><td colspan="8">${t("no_files_found") || "No files found."}</td></tr>`;
|
||||
}
|
||||
|
||||
rowsHTML += "</tbody></table>";
|
||||
|
||||
const bottomControlsHTML = buildBottomControls(itemsPerPageSetting);
|
||||
|
||||
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
|
||||
|
||||
// Inject inline folder rows for THIS page (Explorer-style)
|
||||
if (window.showInlineFolders !== false && pageFolders.length) {
|
||||
injectInlineFolderRows(fileListContent, folder, pageFolders);
|
||||
}
|
||||
wireSelectAll(fileListContent);
|
||||
|
||||
// PATCH each row's preview/thumb to use the secure API URLs
|
||||
if (totalFiles > 0) {
|
||||
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
|
||||
const rowEl = document.getElementById(`file-row-${encodeURIComponent(file.name)}-${startIndex + idx}`);
|
||||
if (!rowEl) return;
|
||||
|
||||
const previewUrl = apiFileUrl(file.folder || folder, file.name, true);
|
||||
|
||||
// Preview button dataset
|
||||
const previewBtn = rowEl.querySelector(".preview-btn");
|
||||
if (previewBtn) {
|
||||
previewBtn.dataset.previewUrl = previewUrl;
|
||||
previewBtn.dataset.previewName = file.name;
|
||||
}
|
||||
|
||||
// Thumbnail (if present)
|
||||
const thumbImg = rowEl.querySelector("img");
|
||||
if (thumbImg) {
|
||||
thumbImg.src = previewUrl;
|
||||
thumbImg.setAttribute("data-cache-key", previewUrl);
|
||||
}
|
||||
|
||||
// Any anchor that might have been built to point at a file path
|
||||
rowEl.querySelectorAll('a[href]').forEach(a => {
|
||||
// Only rewrite obvious file anchors (ignore actions with '#', 'javascript:', etc.)
|
||||
if (/^#|^javascript:/i.test(a.getAttribute('href') || '')) return;
|
||||
a.href = previewUrl;
|
||||
// PATCH each row's preview/thumb to use the secure API URLs
|
||||
if (pageFiles.length > 0) {
|
||||
pageFiles.forEach((file, idx) => {
|
||||
const rowKey = rowIdFor(file, idx);
|
||||
const rowEl = document.getElementById(`file-row-${rowKey}`);
|
||||
if (!rowEl) return;
|
||||
|
||||
const previewUrl = apiFileUrl(file.folder || folder, file.name, true);
|
||||
|
||||
// Preview button dataset
|
||||
const previewBtn = rowEl.querySelector(".preview-btn");
|
||||
if (previewBtn) {
|
||||
previewBtn.dataset.previewUrl = previewUrl;
|
||||
previewBtn.dataset.previewName = file.name;
|
||||
}
|
||||
|
||||
// Thumbnail (if present)
|
||||
const thumbImg = rowEl.querySelector("img");
|
||||
if (thumbImg) {
|
||||
thumbImg.src = previewUrl;
|
||||
thumbImg.setAttribute("data-cache-key", previewUrl);
|
||||
}
|
||||
|
||||
// Any anchor that might have been built to point at a file path
|
||||
rowEl.querySelectorAll('a[href]').forEach(a => {
|
||||
// Only rewrite obvious file anchors (ignore actions with '#', 'javascript:', etc.)
|
||||
if (/^#|^javascript:/i.test(a.getAttribute('href') || '')) return;
|
||||
a.href = previewUrl;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fileListContent.querySelectorAll('.folder-item').forEach(el => {
|
||||
el.addEventListener('click', () => loadFileList(el.dataset.folder));
|
||||
@@ -1147,7 +1687,7 @@ export function renderFileTable(folder, container, subfolders) {
|
||||
renderFileTable(folder, container);
|
||||
});
|
||||
|
||||
// Row-select
|
||||
// Row-select (only file rows have checkboxes; folder rows are ignored here)
|
||||
fileListContent.querySelectorAll("tbody tr").forEach(row => {
|
||||
row.addEventListener("click", e => {
|
||||
const cb = row.querySelector(".file-checkbox");
|
||||
@@ -1156,6 +1696,8 @@ export function renderFileTable(folder, container, subfolders) {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Download buttons
|
||||
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
@@ -1248,12 +1790,15 @@ export function renderFileTable(folder, container, subfolders) {
|
||||
});
|
||||
updateFileActionButtons();
|
||||
|
||||
// Dragstart only for file rows (skip folder rows)
|
||||
document.querySelectorAll("#fileList tbody tr").forEach(row => {
|
||||
if (row.classList.contains("folder-row")) return;
|
||||
row.setAttribute("draggable", "true");
|
||||
import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
|
||||
row.addEventListener("dragstart", module.fileDragStartHandler);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".download-btn, .edit-btn, .rename-btn").forEach(btn => {
|
||||
btn.addEventListener("click", e => e.stopPropagation());
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
@@ -331,7 +331,13 @@ const translations = {
|
||||
"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.",
|
||||
"load_more_folders": "Load More Folders"
|
||||
"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.",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v1.9.11';
|
||||
window.APP_VERSION = 'v1.9.14';
|
||||
|
||||
166
src/controllers/AclAdminController.php
Normal file
166
src/controllers/AclAdminController.php
Normal 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}]}');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
178
src/lib/ACL.php
178
src/lib/ACL.php
@@ -227,6 +227,166 @@ class ACL
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load Pro user groups from FR_PRO_BUNDLE_DIR/users/pro/groups.json.
|
||||
* Returns a map: groupName => ['name','label','members'=>[],'grants'=>[]]
|
||||
* When Pro is inactive or no file exists, returns an empty array.
|
||||
*/
|
||||
private static function loadGroupData(): array
|
||||
{
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) return [];
|
||||
if (!defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) return [];
|
||||
|
||||
static $loaded = false;
|
||||
static $cache = [];
|
||||
static $mtime = 0;
|
||||
|
||||
$base = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\");
|
||||
if ($base === '') return [];
|
||||
|
||||
$file = $base . DIRECTORY_SEPARATOR . 'groups.json';
|
||||
$mt = @filemtime($file) ?: 0;
|
||||
|
||||
if ($loaded && $mtime === $mt) {
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$loaded = true;
|
||||
$mtime = $mt;
|
||||
if (!$mt || !is_file($file)) {
|
||||
$cache = [];
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($file);
|
||||
if ($raw === false || $raw === '') {
|
||||
$cache = [];
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
$cache = [];
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$groups = isset($data['groups']) && is_array($data['groups']) ? $data['groups'] : $data;
|
||||
$norm = [];
|
||||
|
||||
foreach ($groups as $key => $g) {
|
||||
if (!is_array($g)) continue;
|
||||
$name = isset($g['name']) ? (string)$g['name'] : (string)$key;
|
||||
$name = trim($name);
|
||||
if ($name === '') continue;
|
||||
|
||||
$g['name'] = $name;
|
||||
$g['label'] = isset($g['label']) ? (string)$g['label'] : $name;
|
||||
|
||||
if (!isset($g['members']) || !is_array($g['members'])) {
|
||||
$g['members'] = [];
|
||||
} else {
|
||||
$g['members'] = array_values(array_unique(array_map('strval', $g['members'])));
|
||||
}
|
||||
|
||||
if (!isset($g['grants']) || !is_array($g['grants'])) {
|
||||
$g['grants'] = [];
|
||||
}
|
||||
|
||||
$norm[$name] = $g;
|
||||
}
|
||||
|
||||
$cache = $norm;
|
||||
return $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a group grants record for a single folder to a capability bucket.
|
||||
* Supports both internal bucket keys and the UI-style keys: view, viewOwn,
|
||||
* manage, shareFile, shareFolder.
|
||||
*/
|
||||
private static function groupGrantsCap(array $grants, string $capKey): bool
|
||||
{
|
||||
// Direct match (owners, read, write, share, read_own, create, upload, edit, rename, copy, move, delete, extract, share_file, share_folder)
|
||||
if (array_key_exists($capKey, $grants) && $grants[$capKey] === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch ($capKey) {
|
||||
case 'read':
|
||||
return !empty($grants['view']);
|
||||
case 'read_own':
|
||||
// Full view always implies own
|
||||
if (!empty($grants['view'])) return true;
|
||||
return !empty($grants['viewOwn']);
|
||||
case 'share_file':
|
||||
if (!empty($grants['share_file'])) return true;
|
||||
return !empty($grants['shareFile']);
|
||||
case 'share_folder':
|
||||
if (!empty($grants['share_folder'])) return true;
|
||||
return !empty($grants['shareFolder']);
|
||||
case 'write':
|
||||
case 'create':
|
||||
case 'upload':
|
||||
case 'edit':
|
||||
case 'rename':
|
||||
case 'copy':
|
||||
case 'move':
|
||||
case 'delete':
|
||||
case 'extract':
|
||||
if (!empty($grants[$capKey])) return true;
|
||||
// Group "manage" implies all write-ish caps
|
||||
return !empty($grants['manage']);
|
||||
case 'share':
|
||||
if (!empty($grants['share'])) return true;
|
||||
// Manage can optionally imply share; this keeps UI simple
|
||||
return !empty($grants['manage']);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether any Pro group the user belongs to grants this cap for folder.
|
||||
* Groups are additive only; they never remove access.
|
||||
*/
|
||||
private static function groupHasGrant(string $user, string $folder, string $capKey): bool
|
||||
{
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) return false;
|
||||
$user = (string)$user;
|
||||
if ($user === '') return false;
|
||||
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if ($folder === '') $folder = 'root';
|
||||
|
||||
$groups = self::loadGroupData();
|
||||
if (!$groups) return false;
|
||||
|
||||
foreach ($groups as $g) {
|
||||
if (!is_array($g)) continue;
|
||||
|
||||
$members = $g['members'] ?? [];
|
||||
$isMember = false;
|
||||
if (is_array($members)) {
|
||||
foreach ($members as $m) {
|
||||
if (strcasecmp((string)$m, $user) === 0) {
|
||||
$isMember = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$isMember) continue;
|
||||
|
||||
$folderGrants = $g['grants'][$folder] ?? null;
|
||||
if (!is_array($folderGrants)) continue;
|
||||
|
||||
if (self::groupGrantsCap($folderGrants, $capKey)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
private static function save(array $acl): bool
|
||||
{
|
||||
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
|
||||
@@ -286,8 +446,20 @@ class ACL
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$capKey = ($cap === 'owner') ? 'owners' : $cap;
|
||||
$arr = self::listFor($folder, $capKey);
|
||||
foreach ($arr as $u) if (strcasecmp((string)$u, $user) === 0) return true;
|
||||
|
||||
// 1) Core per-folder ACL buckets (folder_acl.json)
|
||||
$arr = self::listFor($folder, $capKey);
|
||||
foreach ($arr as $u) {
|
||||
if (strcasecmp((string)$u, $user) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Pro user groups (if enabled) – additive only
|
||||
if (self::groupHasGrant($user, $folder, $capKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -620,4 +792,4 @@ class ACL
|
||||
// require full view too
|
||||
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'read');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, they’re 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 they’re view_own-only here, don’t 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
|
||||
|
||||
17
start.sh
17
start.sh
@@ -72,23 +72,6 @@ for d in uploads users metadata; do
|
||||
chmod 775 "${tgt}"
|
||||
done
|
||||
|
||||
# 2.4) Sync FileRise Pro public endpoints from persistent bundle
|
||||
BUNDLE_PRO_PUBLIC="/var/www/users/pro/public/api/pro"
|
||||
LIVE_PRO_PUBLIC="/var/www/public/api/pro"
|
||||
|
||||
if [ -d "${BUNDLE_PRO_PUBLIC}" ]; then
|
||||
echo "[startup] Syncing FileRise Pro public endpoints..."
|
||||
mkdir -p "${LIVE_PRO_PUBLIC}"
|
||||
|
||||
# Copy files from bundle to live api/pro (overwrite for upgrades)
|
||||
cp -R "${BUNDLE_PRO_PUBLIC}/." "${LIVE_PRO_PUBLIC}/" || echo "[startup] Pro sync copy failed (continuing)"
|
||||
|
||||
# Normalize ownership/permissions
|
||||
chown -R www-data:www-data "${LIVE_PRO_PUBLIC}" || echo "[startup] chown api/pro failed (continuing)"
|
||||
find "${LIVE_PRO_PUBLIC}" -type d -exec chmod 755 {} \; 2>/dev/null || true
|
||||
find "${LIVE_PRO_PUBLIC}" -type f -exec chmod 644 {} \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 3) Ensure PHP conf dir & set upload limits
|
||||
mkdir -p /etc/php/8.3/apache2/conf.d
|
||||
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||
|
||||
Reference in New Issue
Block a user