Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08a84419f0 | ||
|
|
49d3588322 | ||
|
|
e1b20a9f1d | ||
|
|
0ec8103fbf | ||
|
|
3b1ebdd77f | ||
|
|
3726e2423d | ||
|
|
5613710411 | ||
|
|
08f7ffccbc |
82
CHANGELOG.md
82
CHANGELOG.md
@@ -1,5 +1,87 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 11/19/2025 (v1.9.12)
|
||||
|
||||
release(v1.9.12): feat(pro-acl): add user groups and group-aware ACL
|
||||
|
||||
- Add Pro user groups as a first-class ACL source:
|
||||
- Load group grants from FR_PRO_BUNDLE_DIR/groups.json in ACL::hasGrant().
|
||||
- Treat group grants as additive only; they can never remove access.
|
||||
|
||||
- Introduce AclAdminController:
|
||||
- Move getGrants/saveGrants logic into a dedicated controller.
|
||||
- Keep existing ACL normalization and business rules (shareFolder ⇒ view, shareFile ⇒ at least viewOwn).
|
||||
- Refactor public/api/admin/acl/getGrants.php and saveGrants.php to use the controller.
|
||||
|
||||
- Implement Pro user group storage and APIs:
|
||||
- Add ProGroups store class under FR_PRO_BUNDLE_DIR (groups.json with {name,label,members,grants}).
|
||||
- Add /api/pro/groups/list.php and /api/pro/groups/save.php, guarded by AdminController::requireAuth/requireAdmin/requireCsrf().
|
||||
- Keep groups and bundle code behind FR_PRO_ACTIVE/FR_PRO_BUNDLE_DIR checks.
|
||||
|
||||
- Ship Pro-only endpoints from core instead of the bundle:
|
||||
- Move public/api/pro/uploadBrandLogo.php into core and gate it on FR_PRO_ACTIVE.
|
||||
- Remove start.sh logic that copied public/api/pro from the Pro bundle into the container image.
|
||||
|
||||
- Extend admin UI for user groups:
|
||||
- Turn “User groups” into a real Pro-only modal with add/delete groups, multi-select members, and member chips.
|
||||
- Add “Edit folder access” for each group, reusing the existing folder grants grid.
|
||||
- Overlay group grants when editing a user’s ACL:
|
||||
- Show which caps are coming from groups, lock those checkboxes, and update tooltips.
|
||||
- Show group membership badges in the user permissions list.
|
||||
- Add a collapsed “Groups” section at the top of the permissions screen to preview group ACLs (read-only).
|
||||
|
||||
- Misc:
|
||||
- Bump PRO_LATEST_BUNDLE_VERSION hint in adminPanel.js to v1.0.1.
|
||||
- Tweak modal border-radius styling to include the new userGroups and groupAcl modals.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/18/2025 (v1.9.11)
|
||||
|
||||
release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68)
|
||||
|
||||
- media: add proper HTTP Range support to /api/file/download.php so HTML5
|
||||
video/audio can seek correctly across all browsers (Brave/Chrome/Android/Windows).
|
||||
- media: avoid buffering the entire file in memory; stream from disk with
|
||||
200/206 responses and Accept-Ranges for smoother playback and faster start times.
|
||||
- media: keep video progress tracking, watched badges, and status chip behavior
|
||||
unchanged but now compatible with the new streaming endpoint.
|
||||
|
||||
- ui: update the folder strip to be responsive:
|
||||
- desktop: keep the existing "chip" layout with icon above name.
|
||||
- mobile: switch to inline rows `[icon] [name]` with reduced whitespace.
|
||||
- ui: add simple lazy-loading for the folder strip so only the first batch of
|
||||
folders is rendered initially, with a "Load more…" button to append chunks for
|
||||
very large folder sets (stays friendly with 100k+ folders).
|
||||
|
||||
- misc: small CSS tidy-up around the folder strip classes to remove duplicates
|
||||
and keep mobile/desktop behavior clearly separated.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/18/2025 (v1.9.10)
|
||||
|
||||
release(v1.9.10): add Pro bundle installer and admin panel polish
|
||||
|
||||
- Add FileRise Pro section in admin panel with license management and bundle upload
|
||||
- Persist Pro bundle under users/pro and sync public/api/pro endpoints on container startup
|
||||
- Improve admin config API: Pro metadata, license file handling, hardened auth/CSRF helpers
|
||||
- Update Pro badge/version UI with “update available” hint and link to filerise.net
|
||||
- Change Pro bundle installer to always overwrite existing bundle files for clean upgrades
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/16/2025 (v1.9.9)
|
||||
|
||||
release(v1.9.9): fix(branding): sanitize custom logo URL preview
|
||||
|
||||
- Sanitize branding.customLogoUrl on the server before writing siteConfig.json
|
||||
- Allow only http/https or site-relative paths; strip invalid/sneaky values
|
||||
- Update adminPanel.js live logo preview to set img src/alt safely
|
||||
- Addresses CodeQL XSS warning while keeping Pro branding logo overrides working
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/16/2025 (v1.9.8)
|
||||
|
||||
release(v1.9.8): feat(pro): wire core to Pro licensing + branding hooks
|
||||
|
||||
@@ -240,30 +240,57 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
||||
// Final: env var wins, else fallback
|
||||
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
||||
|
||||
// --------------------------------
|
||||
// FileRise Pro (optional add-on)
|
||||
// --------------------------------
|
||||
// ------------------------------------------------------------
|
||||
// FileRise Pro bootstrap wiring
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// Where the Pro license JSON lives
|
||||
// Inline license (optional; usually set via Admin UI and PRO_LICENSE_FILE)
|
||||
if (!defined('FR_PRO_LICENSE')) {
|
||||
$envLicense = getenv('FR_PRO_LICENSE');
|
||||
define('FR_PRO_LICENSE', $envLicense !== false ? trim((string)$envLicense) : '');
|
||||
}
|
||||
|
||||
// JSON license file used by AdminController::setLicense()
|
||||
if (!defined('PRO_LICENSE_FILE')) {
|
||||
define('PRO_LICENSE_FILE', PROJECT_ROOT . '/users/proLicense.json');
|
||||
}
|
||||
|
||||
// Inline/env license strings (optional)
|
||||
if (!defined('FR_PRO_LICENSE')) {
|
||||
define('FR_PRO_LICENSE', getenv('FR_PRO_LICENSE') ?: '');
|
||||
}
|
||||
// Optional plain-text license file (used as fallback in bootstrap)
|
||||
if (!defined('FR_PRO_LICENSE_FILE')) {
|
||||
define('FR_PRO_LICENSE_FILE', getenv('FR_PRO_LICENSE_FILE') ?: '');
|
||||
$lf = getenv('FR_PRO_LICENSE_FILE');
|
||||
if ($lf === false || $lf === '') {
|
||||
$lf = PROJECT_ROOT . '/users/proLicense.txt';
|
||||
}
|
||||
define('FR_PRO_LICENSE_FILE', $lf);
|
||||
}
|
||||
|
||||
// Optional Pro bootstrap (shipped only with Pro bundle)
|
||||
$proBootstrap = PROJECT_ROOT . '/src/pro/bootstrap_pro.php';
|
||||
if (is_file($proBootstrap)) {
|
||||
// Where Pro code lives by default → inside users volume
|
||||
$proDir = getenv('FR_PRO_BUNDLE_DIR');
|
||||
if ($proDir === false || $proDir === '') {
|
||||
$proDir = PROJECT_ROOT . '/users/pro';
|
||||
}
|
||||
$proDir = rtrim($proDir, "/\\");
|
||||
if (!defined('FR_PRO_BUNDLE_DIR')) {
|
||||
define('FR_PRO_BUNDLE_DIR', $proDir);
|
||||
}
|
||||
|
||||
// Try to load Pro bootstrap if enabled + present
|
||||
$proBootstrap = FR_PRO_BUNDLE_DIR . '/bootstrap_pro.php';
|
||||
if (@is_file($proBootstrap)) {
|
||||
require_once $proBootstrap;
|
||||
}
|
||||
|
||||
// Safe default so the rest of the app always has the constant
|
||||
// If bootstrap didn’t define these, give safe defaults
|
||||
if (!defined('FR_PRO_ACTIVE')) {
|
||||
define('FR_PRO_ACTIVE', false);
|
||||
}
|
||||
if (!defined('FR_PRO_INFO')) {
|
||||
define('FR_PRO_INFO', [
|
||||
'valid' => false,
|
||||
'error' => null,
|
||||
'payload' => null,
|
||||
]);
|
||||
}
|
||||
if (!defined('FR_PRO_BUNDLE_VERSION')) {
|
||||
define('FR_PRO_BUNDLE_VERSION', null);
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
8
public/api/admin/installProBundle.php
Normal file
8
public/api/admin/installProBundle.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
$controller = new AdminController();
|
||||
$controller->installProBundle();
|
||||
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;
|
||||
@@ -1887,4 +1889,94 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
|
||||
color: #111;}
|
||||
.dark-mode .upload-resume-banner-inner .material-icons,
|
||||
.dark-mode .folder-badge .material-icons{background-color: transparent;
|
||||
color: #f5f5f5;}
|
||||
color: #f5f5f5;}
|
||||
/* Base strip container */
|
||||
.folder-strip-container {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Base item layout */
|
||||
.folder-strip-container .folder-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.folder-strip-container .folder-svg {
|
||||
flex: 0 0 auto;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.folder-strip-container .folder-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* --- Desktop: chips, icon above name --- */
|
||||
.folder-strip-container.folder-strip-desktop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.folder-strip-container.folder-strip-desktop .folder-item {
|
||||
flex-direction: column; /* icon on top, name under */
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.folder-strip-container.folder-strip-desktop .folder-name {
|
||||
text-align: center;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* --- Mobile: stacked rows, icon left of name --- */
|
||||
.folder-strip-container.folder-strip-mobile {
|
||||
display: block;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0,0,0,.08);
|
||||
background: rgba(0,0,0,.02);
|
||||
}
|
||||
|
||||
.folder-strip-container.folder-strip-mobile .folder-item {
|
||||
width: 100%;
|
||||
flex-direction: row; /* icon left, name right */
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 8px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.folder-strip-container.folder-strip-mobile .folder-name {
|
||||
flex: 1 1 auto;
|
||||
text-align: left;
|
||||
transform: translate(8px, 4px);
|
||||
|
||||
}
|
||||
|
||||
.folder-strip-container.folder-strip-mobile .folder-item:hover {
|
||||
background: rgba(0,0,0,.04);
|
||||
}
|
||||
|
||||
.folder-strip-container.folder-strip-mobile .folder-item.selected {
|
||||
background: rgba(59,130,246,.12);
|
||||
}
|
||||
|
||||
/* Load-more button */
|
||||
.folder-strip-load-more {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 4px 0 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0,0,0,.15);
|
||||
background: rgba(0,0,0,.02);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ export let fileData = [];
|
||||
export let sortOrder = { column: "uploaded", ascending: true };
|
||||
|
||||
|
||||
|
||||
const FOLDER_STRIP_PAGE_SIZE = 50;
|
||||
// onnlyoffice
|
||||
let OO_ENABLED = false;
|
||||
let OO_EXTS = new Set();
|
||||
@@ -58,6 +58,143 @@ export async function initOnlyOfficeCaps() {
|
||||
}
|
||||
}
|
||||
|
||||
function wireFolderStripItems(strip) {
|
||||
if (!strip) return;
|
||||
|
||||
// Click / DnD / context menu
|
||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||
// 1) click to navigate
|
||||
el.addEventListener("click", () => {
|
||||
const dest = el.dataset.folder;
|
||||
if (!dest) return;
|
||||
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
updateBreadcrumbTitle(dest);
|
||||
|
||||
document.querySelectorAll(".folder-option.selected")
|
||||
.forEach(o => o.classList.remove("selected"));
|
||||
document
|
||||
.querySelector(`.folder-option[data-folder="${dest}"]`)
|
||||
?.classList.add("selected");
|
||||
|
||||
loadFileList(dest);
|
||||
});
|
||||
|
||||
// 2) drag & drop
|
||||
el.addEventListener("dragover", folderDragOverHandler);
|
||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||
el.addEventListener("drop", folderDropHandler);
|
||||
|
||||
// 3) right-click context menu
|
||||
el.addEventListener("contextmenu", e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const dest = el.dataset.folder;
|
||||
if (!dest) return;
|
||||
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
|
||||
strip.querySelectorAll(".folder-item.selected")
|
||||
.forEach(i => i.classList.remove("selected"));
|
||||
el.classList.add("selected");
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// Close menu when clicking elsewhere
|
||||
document.addEventListener("click", hideFolderManagerContextMenu);
|
||||
|
||||
// Folder icons
|
||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||
const full = el.getAttribute('data-folder');
|
||||
if (full) attachStripIconAsync(el, full, 48);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFolderStripPaged(strip, subfolders) {
|
||||
if (!strip) return;
|
||||
|
||||
if (!window.showFoldersInList || !subfolders.length) {
|
||||
strip.style.display = "none";
|
||||
strip.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const total = subfolders.length;
|
||||
const pageSize = FOLDER_STRIP_PAGE_SIZE;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
function drawPage(page) {
|
||||
const endIdx = Math.min(page * pageSize, total);
|
||||
const visible = subfolders.slice(0, endIdx);
|
||||
|
||||
let html = visible.map(sf => `
|
||||
<div class="folder-item"
|
||||
data-folder="${sf.full}"
|
||||
draggable="true">
|
||||
<span class="folder-svg"></span>
|
||||
<div class="folder-name">
|
||||
${escapeHTML(sf.name)}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
if (endIdx < total) {
|
||||
html += `
|
||||
<button type="button"
|
||||
class="folder-strip-load-more">
|
||||
${t('load_more_folders') || t('load_more') || 'Load more folders'}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
strip.innerHTML = html;
|
||||
|
||||
applyFolderStripLayout(strip);
|
||||
wireFolderStripItems(strip);
|
||||
|
||||
const loadMoreBtn = strip.querySelector(".folder-strip-load-more");
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.addEventListener("click", e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
drawPage(page + 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drawPage(1);
|
||||
}
|
||||
|
||||
// helper to repaint one strip item quickly
|
||||
function repaintStripIcon(folder) {
|
||||
@@ -78,6 +215,31 @@ function repaintStripIcon(folder) {
|
||||
iconSpan.innerHTML = folderSVG(kind);
|
||||
}
|
||||
|
||||
function applyFolderStripLayout(strip) {
|
||||
if (!strip) return;
|
||||
const hasItems = strip.querySelector('.folder-item') !== null;
|
||||
if (!hasItems) {
|
||||
strip.style.display = 'none';
|
||||
strip.classList.remove('folder-strip-mobile', 'folder-strip-desktop');
|
||||
return;
|
||||
}
|
||||
|
||||
const isMobile = window.innerWidth <= 640; // tweak breakpoint if you want
|
||||
|
||||
strip.classList.add('folder-strip-container');
|
||||
strip.classList.toggle('folder-strip-mobile', isMobile);
|
||||
strip.classList.toggle('folder-strip-desktop', !isMobile);
|
||||
|
||||
strip.style.display = isMobile ? 'block' : 'flex';
|
||||
strip.style.overflowX = isMobile ? 'visible' : 'auto';
|
||||
strip.style.overflowY = isMobile ? 'auto' : 'hidden';
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const strip = document.getElementById('folderStripContainer');
|
||||
if (strip) applyFolderStripLayout(strip);
|
||||
});
|
||||
|
||||
// Listen once: update strip + tree when folder color changes
|
||||
window.addEventListener('folderColorChanged', (e) => {
|
||||
const { folder } = e.detail || {};
|
||||
@@ -812,93 +974,8 @@ export async function loadFileList(folderParam) {
|
||||
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
|
||||
}
|
||||
|
||||
if (window.showFoldersInList && subfolders.length) {
|
||||
strip.innerHTML = subfolders.map(sf => {
|
||||
return `
|
||||
<div class="folder-item"
|
||||
data-folder="${sf.full}"
|
||||
draggable="true"
|
||||
style="display:flex;align-items:center;gap:10px;min-width:0;">
|
||||
<span class="folder-svg" style="flex:0 0 auto;line-height:0;"></span>
|
||||
<div class="folder-name"
|
||||
style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
${escapeHTML(sf.name)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
strip.style.display = "flex";
|
||||
|
||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||
// 1) click to navigate
|
||||
el.addEventListener("click", () => {
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
updateBreadcrumbTitle(dest);
|
||||
document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected"));
|
||||
document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected");
|
||||
loadFileList(dest);
|
||||
});
|
||||
|
||||
// 2) drag & drop
|
||||
el.addEventListener("dragover", folderDragOverHandler);
|
||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||
el.addEventListener("drop", folderDropHandler);
|
||||
|
||||
// 3) right-click context menu
|
||||
el.addEventListener("contextmenu", e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
|
||||
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
|
||||
el.classList.add("selected");
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("click", hideFolderManagerContextMenu);
|
||||
|
||||
// After wiring events for each .folder-item:
|
||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||
const full = el.getAttribute('data-folder');
|
||||
attachStripIconAsync(el, full, 48);
|
||||
});
|
||||
|
||||
} else {
|
||||
strip.style.display = "none";
|
||||
}
|
||||
// NEW: paged + responsive strip
|
||||
renderFolderStripPaged(strip, subfolders);
|
||||
} catch {
|
||||
// ignore folder errors; rows already rendered
|
||||
}
|
||||
|
||||
@@ -469,102 +469,118 @@ export function previewFile(fileUrl, fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* -------------------- VIDEOS -------------------- */
|
||||
if (isVideo) {
|
||||
let video = document.createElement("video"); // let so we can rebind
|
||||
video.controls = true;
|
||||
video.style.maxWidth = "88vw";
|
||||
video.style.maxHeight = "88vh";
|
||||
video.style.objectFit = "contain";
|
||||
container.appendChild(video);
|
||||
|
||||
// Top-right action icons (Material icons, theme-aware)
|
||||
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||
actionWrap.appendChild(markBtnIcon);
|
||||
actionWrap.appendChild(clearBtnIcon);
|
||||
|
||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||
overlay.mediaType = 'video';
|
||||
overlay.mediaList = videos;
|
||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||
|
||||
const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); };
|
||||
|
||||
const SAVE_INTERVAL_MS = 5000;
|
||||
let lastSaveAt = 0;
|
||||
let pending = false;
|
||||
|
||||
async function getProgress(nm) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return data && data.state ? data.state : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||
try {
|
||||
pending = true;
|
||||
const res = await fetch("/api/media/updateProgress.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||
});
|
||||
const data = await res.json();
|
||||
pending = false;
|
||||
return data;
|
||||
} catch (e) { pending = false; console.error(e); return null; }
|
||||
}
|
||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||
|
||||
function renderStatus(state) {
|
||||
if (!statusChip) return;
|
||||
// Completed
|
||||
if (state && state.completed) {
|
||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||
statusChip.style.display = 'inline-block';
|
||||
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||
statusChip.style.color = '#22c55e';
|
||||
markBtnIcon.style.display = 'none';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
/* -------------------- VIDEOS -------------------- */
|
||||
if (isVideo) {
|
||||
let video = document.createElement("video");
|
||||
video.controls = true;
|
||||
video.preload = 'auto'; // hint browser to start fetching quickly
|
||||
video.style.maxWidth = "88vw";
|
||||
video.style.maxHeight = "88vh";
|
||||
video.style.objectFit = "contain";
|
||||
container.appendChild(video);
|
||||
|
||||
// Top-right action icons (Material icons, theme-aware)
|
||||
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||
actionWrap.appendChild(markBtnIcon);
|
||||
actionWrap.appendChild(clearBtnIcon);
|
||||
|
||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||
overlay.mediaType = 'video';
|
||||
overlay.mediaList = videos;
|
||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||
|
||||
// Track which file is currently active
|
||||
let currentName = name;
|
||||
|
||||
const setVideoSrc = (nm) => {
|
||||
currentName = nm;
|
||||
video.src = buildPreviewUrl(folder, nm);
|
||||
setTitle(overlay, nm);
|
||||
};
|
||||
|
||||
const SAVE_INTERVAL_MS = 5000;
|
||||
let lastSaveAt = 0;
|
||||
let pending = false;
|
||||
|
||||
async function getProgress(nm) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return data && data.state ? data.state : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
// In progress
|
||||
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
statusChip.textContent = `${pct}%`;
|
||||
statusChip.style.display = 'inline-block';
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
|
||||
statusChip.style.color = ORANGE_HEX;
|
||||
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
|
||||
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
||||
|
||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||
try {
|
||||
pending = true;
|
||||
const res = await fetch("/api/media/updateProgress.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||
});
|
||||
const data = await res.json();
|
||||
pending = false;
|
||||
return data;
|
||||
} catch (e) {
|
||||
pending = false;
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||
|
||||
function renderStatus(state) {
|
||||
if (!statusChip) return;
|
||||
|
||||
// Completed
|
||||
if (state && state.completed) {
|
||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||
statusChip.style.display = 'inline-block';
|
||||
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||
statusChip.style.color = '#22c55e';
|
||||
markBtnIcon.style.display = 'none';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
|
||||
// In progress
|
||||
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
statusChip.textContent = `${pct}%`;
|
||||
statusChip.style.display = 'inline-block';
|
||||
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
const ORANGE_HEX = '#ea580c';
|
||||
statusChip.style.color = ORANGE_HEX;
|
||||
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
|
||||
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
||||
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
|
||||
// No progress
|
||||
statusChip.style.display = 'none';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
clearBtnIcon.style.display = 'none';
|
||||
}
|
||||
// No progress
|
||||
statusChip.style.display = 'none';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = 'none';
|
||||
}
|
||||
|
||||
function bindVideoEvents(nm) {
|
||||
const nv = video.cloneNode(true);
|
||||
video.replaceWith(nv);
|
||||
video = nv;
|
||||
|
||||
|
||||
// ---- Event handlers (use currentName instead of rebinding per file) ----
|
||||
video.addEventListener("loadedmetadata", async () => {
|
||||
const nm = currentName;
|
||||
try {
|
||||
const state = await getProgress(nm);
|
||||
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
||||
video.currentTime = state.seconds;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
||||
@@ -577,20 +593,24 @@ export function previewFile(fileUrl, fileName) {
|
||||
renderStatus(null);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
video.addEventListener("timeupdate", async () => {
|
||||
const now = Date.now();
|
||||
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
|
||||
lastSaveAt = now;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
|
||||
const nm = currentName;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
|
||||
sendProgress({ nm, seconds, duration });
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
|
||||
renderStatus({ seconds, duration, completed: false });
|
||||
});
|
||||
|
||||
|
||||
video.addEventListener("ended", async () => {
|
||||
const nm = currentName;
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
@@ -598,50 +618,54 @@ export function previewFile(fileUrl, fileName) {
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
});
|
||||
|
||||
|
||||
markBtnIcon.onclick = async () => {
|
||||
const nm = currentName;
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
};
|
||||
|
||||
clearBtnIcon.onclick = async () => {
|
||||
const nm = currentName;
|
||||
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("progress_cleared") || "Progress cleared");
|
||||
setFileWatchedBadge(nm, false);
|
||||
renderStatus(null);
|
||||
};
|
||||
}
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const nm = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setVideoSrc(nm);
|
||||
bindVideoEvents(nm);
|
||||
};
|
||||
|
||||
if (videos.length > 1) {
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||
const onKey = (e) => {
|
||||
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
|
||||
if (e.key === "ArrowLeft") navigate(-1);
|
||||
if (e.key === "ArrowRight") navigate(+1);
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const nm = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setVideoSrc(nm);
|
||||
renderStatus(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
overlay._onKey = onKey;
|
||||
|
||||
if (videos.length > 1) {
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||
const onKey = (e) => {
|
||||
if (!document.body.contains(overlay)) {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft") navigate(-1);
|
||||
if (e.key === "ArrowRight") navigate(+1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
overlay._onKey = onKey;
|
||||
}
|
||||
|
||||
setVideoSrc(name);
|
||||
renderStatus(null);
|
||||
overlay.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
setVideoSrc(name);
|
||||
renderStatus(null);
|
||||
bindVideoEvents(name);
|
||||
overlay.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
/* -------------------- AUDIO / OTHER -------------------- */
|
||||
if (isAudio) {
|
||||
const audio = document.createElement("audio");
|
||||
|
||||
@@ -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",
|
||||
@@ -330,7 +330,8 @@ const translations = {
|
||||
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
|
||||
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
|
||||
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
|
||||
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder."
|
||||
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
|
||||
"load_more_folders": "Load More Folders"
|
||||
},
|
||||
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.8';
|
||||
window.APP_VERSION = 'v1.9.12';
|
||||
|
||||
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,315 @@ 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');
|
||||
|
||||
try {
|
||||
// Guard rails: method + auth + CSRF
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
return;
|
||||
}
|
||||
|
||||
self::requireAuth();
|
||||
self::requireAdmin();
|
||||
self::requireCsrf();
|
||||
|
||||
// Ensure ZipArchive is available
|
||||
if (!class_exists('\\ZipArchive')) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'ZipArchive extension is required on the server.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic upload validation
|
||||
if (empty($_FILES['bundle']) || !is_array($_FILES['bundle'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing uploaded bundle (field "bundle").']);
|
||||
return;
|
||||
}
|
||||
|
||||
$f = $_FILES['bundle'];
|
||||
|
||||
if (!empty($f['error']) && $f['error'] !== UPLOAD_ERR_OK) {
|
||||
$msg = 'Upload error.';
|
||||
switch ($f['error']) {
|
||||
case UPLOAD_ERR_INI_SIZE:
|
||||
case UPLOAD_ERR_FORM_SIZE:
|
||||
$msg = 'Uploaded file exceeds size limit.';
|
||||
break;
|
||||
case UPLOAD_ERR_PARTIAL:
|
||||
$msg = 'Uploaded file was only partially received.';
|
||||
break;
|
||||
case UPLOAD_ERR_NO_FILE:
|
||||
$msg = 'No file was uploaded.';
|
||||
break;
|
||||
default:
|
||||
$msg = 'Upload failed with error code ' . (int)$f['error'];
|
||||
break;
|
||||
}
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => $msg]);
|
||||
return;
|
||||
}
|
||||
|
||||
$tmpName = $f['tmp_name'] ?? '';
|
||||
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid uploaded file.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard against unexpectedly large bundles (e.g., >100MB)
|
||||
$size = isset($f['size']) ? (int)$f['size'] : 0;
|
||||
if ($size <= 0 || $size > 100 * 1024 * 1024) {
|
||||
http_response_code(413);
|
||||
echo json_encode(['success' => false, 'error' => 'Bundle size is invalid or too large (max 100MB).']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional: require .zip extension by name (best-effort)
|
||||
$origName = (string)($f['name'] ?? '');
|
||||
if ($origName !== '' && !preg_match('/\.zip$/i', $origName)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Bundle must be a .zip file.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare temp working dir
|
||||
$tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
|
||||
$workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
|
||||
if (!@mkdir($workDir, 0700, true)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to prepare temp dir.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$zipPath = $workDir . DIRECTORY_SEPARATOR . 'bundle.zip';
|
||||
if (!@move_uploaded_file($tmpName, $zipPath)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to move uploaded bundle.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to open ZIP bundle.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$installed = [
|
||||
'src' => [],
|
||||
'docs' => [],
|
||||
];
|
||||
|
||||
$projectRoot = rtrim(PROJECT_ROOT, DIRECTORY_SEPARATOR);
|
||||
|
||||
// Where Pro bundle code lives (defaults to PROJECT_ROOT . '/users/pro')
|
||||
$bundleRoot = defined('FR_PRO_BUNDLE_DIR')
|
||||
? rtrim(FR_PRO_BUNDLE_DIR, DIRECTORY_SEPARATOR)
|
||||
: ($projectRoot . DIRECTORY_SEPARATOR . 'users' . DIRECTORY_SEPARATOR . 'pro');
|
||||
|
||||
// Put README-Pro.txt / LICENSE-Pro.txt inside the bundle dir as well
|
||||
$proDocsDir = $bundleRoot;
|
||||
if (!is_dir($proDocsDir)) {
|
||||
@mkdir($proDocsDir, 0755, true);
|
||||
}
|
||||
|
||||
$allowedTopLevel = ['LICENSE-Pro.txt', 'README-Pro.txt'];
|
||||
|
||||
// Iterate entries and selectively extract/copy expected files only
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if ($name === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalise and guard
|
||||
$name = ltrim($name, "/\\");
|
||||
if ($name === '' || substr($name, -1) === '/') {
|
||||
continue; // skip directories
|
||||
}
|
||||
if (strpos($name, '../') !== false || strpos($name, '..\\') !== false) {
|
||||
continue; // path traversal guard
|
||||
}
|
||||
|
||||
// Ignore macOS Finder junk: __MACOSX and "._" resource forks
|
||||
$base = basename($name);
|
||||
if (
|
||||
str_starts_with($name, '__MACOSX/') ||
|
||||
str_contains($name, '/__MACOSX/') ||
|
||||
str_starts_with($base, '._')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetPath = null;
|
||||
$category = null;
|
||||
|
||||
if (in_array($name, $allowedTopLevel, true)) {
|
||||
// Docs → bundle dir (under /users/pro)
|
||||
$targetPath = $proDocsDir . DIRECTORY_SEPARATOR . $name;
|
||||
$category = 'docs';
|
||||
|
||||
} elseif (strpos($name, 'src/pro/') === 0) {
|
||||
// e.g. src/pro/bootstrap_pro.php -> FR_PRO_BUNDLE_DIR/bootstrap_pro.php
|
||||
$relative = substr($name, strlen('src/pro/'));
|
||||
if ($relative === '' || substr($relative, -1) === '/') {
|
||||
continue;
|
||||
}
|
||||
$targetPath = $bundleRoot . DIRECTORY_SEPARATOR . $relative;
|
||||
$category = 'src';
|
||||
|
||||
} else {
|
||||
// Skip anything outside these prefixes
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$targetPath || !$category) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track whether we're overwriting an existing file (for reporting only)
|
||||
$wasExisting = is_file($targetPath);
|
||||
|
||||
// Read from ZIP entry
|
||||
$stream = $zip->getStream($name);
|
||||
if (!$stream) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dir = dirname($targetPath);
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
||||
fclose($stream);
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to create destination directory for ' . $name]);
|
||||
return;
|
||||
}
|
||||
|
||||
$data = stream_get_contents($stream);
|
||||
fclose($stream);
|
||||
if ($data === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to read data for ' . $name]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always overwrite target file on install/upgrade
|
||||
if (@file_put_contents($targetPath, $data) === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to write ' . $name]);
|
||||
return;
|
||||
}
|
||||
|
||||
@chmod($targetPath, 0644);
|
||||
|
||||
// Track what we installed (and whether it was overwritten)
|
||||
if (!isset($installed[$category])) {
|
||||
$installed[$category] = [];
|
||||
}
|
||||
$installed[$category][] = $targetPath . ($wasExisting ? ' (overwritten)' : '');
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
// Best-effort cleanup; ignore failures
|
||||
@unlink($zipPath);
|
||||
@rmdir($workDir);
|
||||
|
||||
// Reflect current Pro status in response if bootstrap was loaded
|
||||
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
|
||||
$proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)
|
||||
? (FR_PRO_INFO['payload'] ?? null)
|
||||
: null;
|
||||
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Pro bundle installed.',
|
||||
'installed' => $installed,
|
||||
'proActive' => (bool)$proActive,
|
||||
'proVersion' => $proVersion,
|
||||
'proPayload' => $proPayload,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Exception during bundle install: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateConfig(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
@@ -643,25 +643,137 @@ public function deleteFiles()
|
||||
} finally { $this->_jsonEnd(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a file with proper HTTP Range support so HTML5 video/audio can seek.
|
||||
*
|
||||
* @param string $fullPath Absolute filesystem path
|
||||
* @param string $downloadName Name shown in Content-Disposition
|
||||
* @param string $mimeType MIME type (from FileModel::getDownloadInfo)
|
||||
* @param bool $inline true => inline, false => attachment
|
||||
*/
|
||||
private function streamFileWithRange(string $fullPath, string $downloadName, string $mimeType, bool $inline): void
|
||||
{
|
||||
if (!is_file($fullPath) || !is_readable($fullPath)) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'File not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$size = (int)@filesize($fullPath);
|
||||
$start = 0;
|
||||
$end = $size > 0 ? $size - 1 : 0;
|
||||
|
||||
if ($size < 0) {
|
||||
$size = 0;
|
||||
$end = 0;
|
||||
}
|
||||
|
||||
// Close session + disable output buffering for streaming
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_write_close();
|
||||
}
|
||||
if (function_exists('apache_setenv')) {
|
||||
@apache_setenv('no-gzip', '1');
|
||||
}
|
||||
@ini_set('zlib.output_compression', '0');
|
||||
@ini_set('output_buffering', 'off');
|
||||
while (ob_get_level() > 0) {
|
||||
@ob_end_clean();
|
||||
}
|
||||
|
||||
$disposition = $inline ? 'inline' : 'attachment';
|
||||
$mime = $mimeType ?: 'application/octet-stream';
|
||||
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Accept-Ranges: bytes');
|
||||
header("Content-Type: {$mime}");
|
||||
header("Content-Disposition: {$disposition}; filename=\"" . basename($downloadName) . "\"");
|
||||
|
||||
// Handle HTTP Range header (single range)
|
||||
$length = $size;
|
||||
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=\s*(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)) {
|
||||
if ($m[1] !== '') {
|
||||
$start = (int)$m[1];
|
||||
}
|
||||
if ($m[2] !== '') {
|
||||
$end = (int)$m[2];
|
||||
}
|
||||
|
||||
// clamp to file size
|
||||
if ($start < 0) $start = 0;
|
||||
if ($end < $start) $end = $start;
|
||||
if ($end >= $size) $end = $size - 1;
|
||||
|
||||
$length = $end - $start + 1;
|
||||
|
||||
http_response_code(206);
|
||||
header("Content-Range: bytes {$start}-{$end}/{$size}");
|
||||
header("Content-Length: {$length}");
|
||||
} else {
|
||||
// no range => full file
|
||||
http_response_code(200);
|
||||
if ($size > 0) {
|
||||
header("Content-Length: {$size}");
|
||||
}
|
||||
}
|
||||
|
||||
$fp = @fopen($fullPath, 'rb');
|
||||
if ($fp === false) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'Unable to open file.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($start > 0) {
|
||||
@fseek($fp, $start);
|
||||
}
|
||||
|
||||
$bytesToSend = $length;
|
||||
$chunkSize = 8192;
|
||||
|
||||
while ($bytesToSend > 0 && !feof($fp)) {
|
||||
$readSize = ($bytesToSend > $chunkSize) ? $chunkSize : $bytesToSend;
|
||||
$buffer = fread($fp, $readSize);
|
||||
if ($buffer === false) {
|
||||
break;
|
||||
}
|
||||
echo $buffer;
|
||||
flush();
|
||||
$bytesToSend -= strlen($buffer);
|
||||
|
||||
if (connection_aborted()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function downloadFile()
|
||||
{
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = isset($_GET['file']) ? basename($_GET['file']) : '';
|
||||
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
|
||||
$file = isset($_GET['file']) ? basename((string)$_GET['file']) : '';
|
||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||
$inlineParam = isset($_GET['inline']) && (string)$_GET['inline'] === '1';
|
||||
|
||||
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Invalid file name."]);
|
||||
exit;
|
||||
}
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
@@ -681,6 +793,7 @@ public function deleteFiles()
|
||||
|
||||
if (!$fullView && !$ownGrant) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Forbidden: no view access to this folder."]);
|
||||
exit;
|
||||
}
|
||||
@@ -690,6 +803,7 @@ public function deleteFiles()
|
||||
$meta = $this->loadFolderMetadata($folder);
|
||||
if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
|
||||
exit;
|
||||
}
|
||||
@@ -697,25 +811,25 @@ public function deleteFiles()
|
||||
|
||||
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
|
||||
if (isset($downloadInfo['error'])) {
|
||||
http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400);
|
||||
http_response_code(in_array($downloadInfo['error'], ["File not found.", "Access forbidden."]) ? 404 : 400);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => $downloadInfo['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$realFilePath = $downloadInfo['filePath'];
|
||||
$mimeType = $downloadInfo['mimeType'];
|
||||
header("Content-Type: " . $mimeType);
|
||||
|
||||
// Decide inline vs attachment:
|
||||
// - if ?inline=1 => always inline (used by filePreview.js)
|
||||
// - else keep your old behavior: images inline, everything else attachment
|
||||
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
||||
$inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico'];
|
||||
if (in_array($ext, $inlineImageTypes, true)) {
|
||||
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
|
||||
} else {
|
||||
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
|
||||
}
|
||||
header('Content-Length: ' . filesize($realFilePath));
|
||||
readfile($realFilePath);
|
||||
exit;
|
||||
|
||||
$inline = $inlineParam || in_array($ext, $inlineImageTypes, true);
|
||||
|
||||
// Stream with proper Range support for video/audio seeking
|
||||
$this->streamFileWithRange($realFilePath, basename($realFilePath), $mimeType, $inline);
|
||||
}
|
||||
|
||||
public function zipStatus()
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user