Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
175c5f962f | ||
|
|
827e65e367 | ||
|
|
fd8029a6bf | ||
|
|
de79395c3d | ||
|
|
aa6f40bc24 | ||
|
|
abc105e087 | ||
|
|
d3bcac4db0 | ||
|
|
0b065111b0 | ||
|
|
3589a1c232 | ||
|
|
1b4a93b060 | ||
|
|
bf077b142b | ||
|
|
f78e2f3f16 | ||
|
|
08a84419f0 | ||
|
|
49d3588322 | ||
|
|
e1b20a9f1d | ||
|
|
0ec8103fbf |
169
CHANGELOG.md
@@ -1,6 +1,173 @@
|
||||
# Changelog
|
||||
|
||||
## changes 11/18/2025 (v1.9.10)
|
||||
## Changes 11/23/2025 (v2.0.2)
|
||||
|
||||
release(v2.0.2): add config-driven demo mode and lock demo account changes
|
||||
|
||||
- Wire FR_DEMO_MODE through AdminModel/siteConfig and admin getConfig (demoMode flag)
|
||||
- Drive demo detection in JS from __FR_SITE_CFG__.demoMode instead of hostname
|
||||
- Show consistent login tip + toasts for demo using shared __FR_DEMO__ flag
|
||||
- Block password changes for the demo user and profile picture uploads when in demo mode
|
||||
- Keep normal user dropdown/admin UI visible even on the demo, while still protecting the demo account
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/23/2025 (v2.0.0)
|
||||
|
||||
### FileRise Core v2.0.0 & FileRise Pro v1.1.0
|
||||
|
||||
```text
|
||||
release(v2.0.0): feat(pro): client portals + portal login flow
|
||||
release(v2.0.1): fix: harden portal + core login redirects for codeql
|
||||
```
|
||||
|
||||
### Core v2.0.0
|
||||
|
||||
- **Portal plumbing in core**
|
||||
- New public pages: `portal.html` and `portal-login.html` for client-facing views.
|
||||
- New portal controller + API endpoints that read portal definitions from the Pro bundle, enforce expiry, and expose safe public metadata.
|
||||
- Login flow now respects a `?redirect=` parameter so portals can bounce through login cleanly and land back on the right slug.
|
||||
|
||||
- **Admin UX + styling**
|
||||
- Admin panel CSS pulled into a dedicated `adminPanelStyles.js` helper instead of inline styles.
|
||||
- User Groups and Client Portals modals use the new shared styling and dark-mode tweaks so they match the rest of the UI.
|
||||
|
||||
- **Breadcrumb root fix**
|
||||
- Breadcrumbs now always show **root** explicitly and behave correctly when you’re at top level vs nested folders.
|
||||
|
||||
- **Routing**
|
||||
- Apache rewrite added for pretty portal URLs:
|
||||
`https://host/portal/<slug>` → `portal.html?slug=<slug>` without affecting other routes.
|
||||
|
||||
### Pro v1.1.0 – Client Portals
|
||||
|
||||
- **Client portal definitions (Admin → FileRise Pro → Client Portals)**
|
||||
- Create multiple portals, each with:
|
||||
- Slug + display name
|
||||
- Target folder
|
||||
- Optional client email
|
||||
- Upload-only / allow-download flags
|
||||
- Per-portal expiry date
|
||||
- Portal-level copy and branding:
|
||||
- Optional title + instructions
|
||||
- Accent color used throughout the portal UI
|
||||
- Footer text at bottom of the portal page
|
||||
|
||||
- **Optional intake form before uploads**
|
||||
- Enable a form per portal with fields: name, email, reference, notes.
|
||||
- Per-field “default value” and “required” toggles.
|
||||
- Form must be completed before uploads when enabled.
|
||||
|
||||
- **Submissions log**
|
||||
- Each portal keeps a submissions list showing:
|
||||
- Date/time, folder, submitting user, IP address
|
||||
- The intake form values (name, email, reference, notes).
|
||||
|
||||
- **Client-facing experience**
|
||||
- New portal UI with:
|
||||
- Branded header (title + accent color)
|
||||
- Optional intake form
|
||||
- Drag-and-drop upload dropzone
|
||||
- If downloads are enabled, a clean list/grid of files already in that portal’s folder with download buttons.
|
||||
|
||||
- **Portal login page**
|
||||
- Minimal login screen that pulls title/accent/footer from portal metadata.
|
||||
- After successful login, user is redirected back to the original portal URL.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
19
README.md
@@ -10,22 +10,23 @@
|
||||
[](https://github.com/sponsors/error311)
|
||||
[](https://ko-fi.com/error311)
|
||||
|
||||
**FileRise** is a modern, self‑hosted web file manager / WebDAV server.
|
||||
Drag & drop uploads, ACL‑aware sharing, OnlyOffice integration, and a clean UI — all in a single PHP app that you control.
|
||||
**FileRise** is a modern, self-hosted web file manager / WebDAV server.
|
||||
Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI — all in a single PHP app that you control.
|
||||
|
||||
- 💾 **Self‑hosted “cloud drive”** – Runs anywhere with PHP (or via Docker). No external DB required.
|
||||
- 🔐 **Granular per‑folder ACLs** – View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
|
||||
- 🔄 **Fast drag‑and‑drop uploads** – Chunked, resumable uploads with pause/resume and progress.
|
||||
- 💾 **Self-hosted “cloud drive”** – Runs anywhere with PHP (or via Docker). No external DB required.
|
||||
- 🔐 **Granular per-folder ACLs** – View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
|
||||
- 🔄 **Fast drag-and-drop uploads** – Chunked, resumable uploads with pause/resume and progress.
|
||||
- 🌳 **Scales to huge trees** – Tested with **100k+ folders** in the sidebar tree.
|
||||
- 🧩 **ONLYOFFICE support (optional)** – Edit DOCX/XLSX/PPTX using your own Document Server.
|
||||
- 🌍 **WebDAV** – Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP.
|
||||
- 🎨 **Polished UI** – Dark/light mode, responsive layout, in‑browser previews & code editor.
|
||||
- 🎨 **Polished UI** – Dark/light mode, responsive layout, in-browser previews & code editor.
|
||||
- 🔑 **Login + SSO** – Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
|
||||
- 👥 **User groups & client portals (Pro)** – Group-based ACLs and brandable client upload portals.
|
||||
|
||||

|
||||

|
||||
|
||||
> 💡 Looking for **FileRise Pro** (brandable header, Pro features, license handling)?
|
||||
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open‑source (MIT).
|
||||
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
|
||||
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open-source (MIT).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ define('REGEX_FOLDER_NAME','/^(?!^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)(?!.*[.
|
||||
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
||||
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
|
||||
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
||||
define('FR_DEMO_MODE', false);
|
||||
|
||||
date_default_timezone_set(TIMEZONE);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ RewriteRule - - [L]
|
||||
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
|
||||
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
||||
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
||||
RewriteRule ^portal/([A-Za-z0-9_-]+)$ portal.html?slug=$1 [L,QSA]
|
||||
|
||||
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
|
||||
# - allow /api/*.php (API endpoints)
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
]);
|
||||
}
|
||||
27
public/api/pro/portals/get.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// public/api/pro/portals/get.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/PortalController.php';
|
||||
|
||||
try {
|
||||
$slug = isset($_GET['slug']) ? (string)$_GET['slug'] : '';
|
||||
|
||||
// For v1: we do NOT require auth here; this is just metadata,
|
||||
// real ACL/access control must still be enforced at upload/download endpoints.
|
||||
$portal = PortalController::getPortalBySlug($slug);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'portal' => $portal,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
32
public/api/pro/portals/list.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// public/api/pro/portals/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();
|
||||
$portals = $ctrl->getProPortals();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'portals' => $portals,
|
||||
], 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' => $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
108
public/api/pro/portals/publicMeta.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
// public/api/pro/portals/publicMeta.php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
|
||||
// --- Basic Pro checks ---
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'FileRise Pro is not active.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$slug = isset($_GET['slug']) ? trim((string)$_GET['slug']) : '';
|
||||
if ($slug === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Missing portal slug.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Locate portals.json written by saveProPortals() ---
|
||||
$bundleDir = defined('FR_PRO_BUNDLE_DIR') ? (string)FR_PRO_BUNDLE_DIR : '';
|
||||
if ($bundleDir === '' || !is_dir($bundleDir)) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Pro bundle directory not found.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$jsonPath = rtrim($bundleDir, "/\\") . '/portals.json';
|
||||
if (!is_file($jsonPath)) {
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'No portals defined.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($jsonPath);
|
||||
if ($raw === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Could not read portals store.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Invalid portals store.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$portals = $data['portals'] ?? [];
|
||||
if (!is_array($portals) || !isset($portals[$slug]) || !is_array($portals[$slug])) {
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Portal not found.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$portal = $portals[$slug];
|
||||
|
||||
// Optional: handle expiry if you’re using expiresAt as ISO date string
|
||||
if (!empty($portal['expiresAt'])) {
|
||||
$ts = strtotime((string)$portal['expiresAt']);
|
||||
if ($ts !== false && $ts < time()) {
|
||||
http_response_code(410); // Gone
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'This portal has expired.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Only expose the bits the login page needs (no folder, email, etc.)
|
||||
$public = [
|
||||
'slug' => $slug,
|
||||
'label' => (string)($portal['label'] ?? ''),
|
||||
'title' => (string)($portal['title'] ?? ''),
|
||||
'introText' => (string)($portal['introText'] ?? ''),
|
||||
'brandColor' => (string)($portal['brandColor'] ?? ''),
|
||||
'footerText' => (string)($portal['footerText'] ?? ''),
|
||||
];
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'portal' => $public,
|
||||
]);
|
||||
51
public/api/pro/portals/save.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
// public/api/pro/portals/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 body']);
|
||||
return;
|
||||
}
|
||||
|
||||
$portals = $body['portals'] ?? null;
|
||||
if (!is_array($portals)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid or missing "portals" payload']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ctrl = new AdminController();
|
||||
$ctrl->saveProPortals($portals);
|
||||
|
||||
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' => $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
64
public/api/pro/portals/submissions.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
|
||||
try {
|
||||
// --- Basic auth / admin check (keep it simple & consistent with your other admin APIs)
|
||||
@session_start();
|
||||
|
||||
$username = (string)($_SESSION['username'] ?? '');
|
||||
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||
|
||||
if ($username === '' || !$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Forbidden',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot done, release lock for concurrency
|
||||
@session_write_close();
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
$slug = isset($_GET['slug']) ? trim((string)$_GET['slug']) : '';
|
||||
if ($slug === '') {
|
||||
throw new InvalidArgumentException('Missing slug.');
|
||||
}
|
||||
|
||||
// Use your ProPortalSubmissions helper from the bundle
|
||||
$proSubmissionsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortalSubmissions.php';
|
||||
if (!is_file($proSubmissionsPath)) {
|
||||
throw new RuntimeException('ProPortalSubmissions.php not found in Pro bundle.');
|
||||
}
|
||||
require_once $proSubmissionsPath;
|
||||
|
||||
$store = new ProPortalSubmissions((string)FR_PRO_BUNDLE_DIR);
|
||||
$submissions = $store->listBySlug($slug, 200);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'slug' => $slug,
|
||||
'submissions' => $submissions,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Server error: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
91
public/api/pro/portals/submitForm.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
// public/api/pro/portals/submitForm.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/PortalController.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();
|
||||
}
|
||||
|
||||
// For now, portal forms still require a logged-in user
|
||||
AdminController::requireAuth();
|
||||
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 body']);
|
||||
return;
|
||||
}
|
||||
|
||||
$slug = isset($body['slug']) ? trim((string)$body['slug']) : '';
|
||||
if ($slug === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing portal slug']);
|
||||
return;
|
||||
}
|
||||
|
||||
$form = isset($body['form']) && is_array($body['form']) ? $body['form'] : [];
|
||||
$name = trim((string)($form['name'] ?? ''));
|
||||
$email = trim((string)($form['email'] ?? ''));
|
||||
$reference = trim((string)($form['reference'] ?? ''));
|
||||
$notes = trim((string)($form['notes'] ?? ''));
|
||||
|
||||
// Make sure portal exists and is not expired
|
||||
$portal = PortalController::getPortalBySlug($slug);
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
$subPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortalSubmissions.php';
|
||||
if (!is_file($subPath)) {
|
||||
throw new RuntimeException('ProPortalSubmissions.php not found in Pro bundle.');
|
||||
}
|
||||
require_once $subPath;
|
||||
|
||||
$submittedBy = (string)($_SESSION['username'] ?? '');
|
||||
$payload = [
|
||||
'slug' => $slug,
|
||||
'portalLabel' => $portal['label'] ?? '',
|
||||
'folder' => $portal['folder'] ?? '',
|
||||
'form' => [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'reference' => $reference,
|
||||
'notes' => $notes,
|
||||
],
|
||||
'submittedBy' => $submittedBy,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'createdAt' => gmdate('c'),
|
||||
];
|
||||
|
||||
$store = new ProPortalSubmissions(FR_PRO_BUNDLE_DIR);
|
||||
$ok = $store->store($slug, $payload);
|
||||
if (!$ok) {
|
||||
throw new RuntimeException('Failed to store portal submission.');
|
||||
}
|
||||
|
||||
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' => $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
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,10 @@ 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,
|
||||
#clientPortalsModal .modal-content{border-radius: var(--menu-radius);}
|
||||
#fr-login-tip{min-height: 40px;
|
||||
max-width: 520px;
|
||||
margin: 8px auto 0;
|
||||
@@ -612,7 +615,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 +820,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 +974,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 +1006,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 +1722,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 +1868,6 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--tree-ghost-border);
|
||||
background: var(--tree-ghost-bg);
|
||||
color: var(--tree-ghost-fg);
|
||||
@@ -1887,4 +1903,193 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
|
||||
color: #111;}
|
||||
.dark-mode .upload-resume-banner-inner .material-icons,
|
||||
.dark-mode .folder-badge .material-icons{background-color: transparent;
|
||||
color: #f5f5f5;}
|
||||
color: #f5f5f5;}
|
||||
/* Base strip container */
|
||||
.folder-strip-container {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Base item layout */
|
||||
.folder-strip-container .folder-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.folder-strip-container .folder-svg {
|
||||
flex: 0 0 auto;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.folder-strip-container .folder-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* --- Desktop: chips, icon above name --- */
|
||||
.folder-strip-container.folder-strip-desktop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.folder-strip-container.folder-strip-desktop .folder-item {
|
||||
flex-direction: column; /* icon on top, name under */
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.folder-strip-container.folder-strip-desktop .folder-name {
|
||||
text-align: center;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* --- Mobile: stacked rows, icon left of name --- */
|
||||
.folder-strip-container.folder-strip-mobile {
|
||||
display: block;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0,0,0,.08);
|
||||
background: rgba(0,0,0,.02);
|
||||
}
|
||||
|
||||
.folder-strip-container.folder-strip-mobile .folder-item {
|
||||
width: 100%;
|
||||
flex-direction: row; /* icon left, name right */
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 8px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.folder-strip-container.folder-strip-mobile .folder-name {
|
||||
flex: 1 1 auto;
|
||||
text-align: left;
|
||||
transform: translate(8px, 4px);
|
||||
|
||||
}
|
||||
|
||||
.folder-strip-container.folder-strip-mobile .folder-item:hover {
|
||||
background: rgba(0,0,0,.04);
|
||||
}
|
||||
|
||||
.folder-strip-container.folder-strip-mobile .folder-item.selected {
|
||||
background: rgba(59,130,246,.12);
|
||||
}
|
||||
|
||||
/* Load-more button */
|
||||
.folder-strip-load-more {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 4px 0 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0,0,0,.15);
|
||||
background: rgba(0,0,0,.02);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* ============================================
|
||||
FileRise polish – compact theme layer
|
||||
============================================ */
|
||||
:root{--filr-radius-lg:14px;--filr-radius-xl:18px;--filr-shadow-soft:0 12px 35px rgba(15,23,42,.14);--filr-shadow-subtle:0 8px 20px rgba(15,23,42,.10);--filr-header-blur:18px;--filr-transition-fast:150ms ease-out;--filr-transition-med:220ms cubic-bezier(.22,.61,.36,1);--fr-bg-dark:#0f0f0f;--fr-surface-dark:#212121;--fr-surface-dark-2:#181818;--fr-border-dark:#303030;--fr-muted-dark:#aaaaaa;--fr-bg-light:#f9f9f9;--fr-surface-light:#ffffff;--fr-surface-light-2:#f1f1f1;--fr-border-light:#e5e5e5;--fr-muted-light:#606060}
|
||||
.btn-pro-admin{background:linear-gradient(135deg,#ff9800,#ff5722);border-color:#ff9800;color:#1b0f00!important;font-weight:600;box-shadow:0 0 10px rgba(255,152,0,.4)}
|
||||
#customToast{border-radius:999px}
|
||||
#folderTreeContainer .folder-row{border-radius:8px}
|
||||
.btn,#customChooseBtn, #colorFolderModal .btn-ghost, #cancelMoveFolder, #confirmMoveFolder, #cancelRenameFolder, #submitRenameFolder, #cancelDeleteFolder, #confirmDeleteFolder, #cancelCreateFolder, #submitCreateFolder{border-radius:999px;font-weight:500;border:1px solid transparent;transition:background-color var(--filr-transition-fast),box-shadow var(--filr-transition-fast),transform var(--filr-transition-fast),border-color var(--filr-transition-fast)}
|
||||
.btn-primary,#createBtn,#uploadBtn,#submitCreateFolder,#submitRenameFolder,#confirmMoveFolder{box-shadow:0 2px 4px rgba(0,0,0,.6)}
|
||||
.btn-primary:hover,#createBtn:hover,#uploadBtn:hover,#submitCreateFolder:hover,#submitRenameFolder:hover,#confirmMoveFolder:hover{filter:brightness(1.04);transform:translateY(-1px);box-shadow:0 10px 22px rgba(0,140,180,.28)}
|
||||
#deleteSelectedBtn,#deleteAllBtn,#deleteTrashSelectedBtn,#deleteFolderBtn,#confirmDeleteFolder{border-color:rgba(248,113,113,.9);box-shadow:0 8px 18px rgba(248,113,113,.35)}
|
||||
input[type=text],input[type=password],input[type=email],input[type=url],select,textarea{border-radius:10px;padding:8px 10px;font-size:.92rem;transition:border-color var(--filr-transition-fast),box-shadow var(--filr-transition-fast),background-color var(--filr-transition-fast)}
|
||||
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--filr-accent-500);box-shadow:0 0 0 1px var(--filr-accent-ring)}
|
||||
.modal{backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
|
||||
#fileListContainer,#uploadCard,#folderManagementCard,.card,.admin-panel-content{border-radius:var(--filr-radius-xl);border:1px solid rgba(15,23,42,.06);background:#ffffff;box-shadow:var(--filr-shadow-subtle)}
|
||||
body{min-height:100vh}
|
||||
body.dark-mode{background:var(--fr-bg-dark)!important;color:#f1f1f1!important;background-image:none!important}
|
||||
body.dark-mode #fileListContainer,body.dark-mode #uploadCard,body.dark-mode #folderManagementCard,body.dark-mode .card,body.dark-mode .admin-panel-content,body.dark-mode .media-topbar{background:var(--fr-surface-dark)!important;border-color:var(--fr-border-dark)!important;box-shadow:0 1px 4px rgba(0,0,0,.9)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
|
||||
body.dark-mode #fileListContainer::before,body.dark-mode #uploadCard::before,body.dark-mode #folderManagementCard::before,body.dark-mode .card::before,body.dark-mode .admin-panel-content::before{box-shadow:none!important}
|
||||
body.dark-mode .card-header,body.dark-mode .custom-folder-card-body .drag-header{background:var(--fr-surface-dark-2)!important;border-bottom:1px solid var(--fr-border-dark)!important}
|
||||
body.dark-mode #fileList table thead th{background:var(--fr-surface-dark-2)!important;border-bottom:1px solid var(--fr-border-dark)!important}
|
||||
body.dark-mode #fileList table.filr-table tbody tr.selected>td,body.dark-mode #fileList table.filr-table tbody tr.row-selected>td,body.dark-mode #fileList table.filr-table tbody tr.selected-row>td,body.dark-mode #fileList table.filr-table tbody tr.is-selected>td{background:rgba(62,166,255,.16)!important;box-shadow:none!important}
|
||||
body.dark-mode .modal{background-color:rgba(0,0,0,.65)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
|
||||
body.dark-mode .modal .modal-content,body.dark-mode .editor-modal,body.dark-mode .image-preview-modal-content,body.dark-mode #restoreFilesModal .modal-content,body.dark-mode #downloadProgressModal .modal-content{background:var(--fr-surface-dark)!important;border-radius:12px!important;border:1px solid var(--fr-border-dark)!important;box-shadow:0 8px 24px rgba(0,0,0,.9)!important}
|
||||
body.dark-mode .modal .modal-content::before,body.dark-mode .editor-modal::before,body.dark-mode .image-preview-modal-content::before,body.dark-mode #restoreFilesModal .modal-content::before,body.dark-mode #downloadProgressModal .modal-content::before{box-shadow:none!important}
|
||||
body.dark-mode input[type=text],body.dark-mode input[type=password],body.dark-mode input[type=email],body.dark-mode input[type=url],body.dark-mode select,body.dark-mode textarea{background:#121212!important;border-color:#3d3d3d!important;color:#f1f1f1!important}
|
||||
body.dark-mode input::placeholder,body.dark-mode textarea::placeholder{color:#777!important}
|
||||
body.dark-mode input:focus,body.dark-mode select:focus,body.dark-mode textarea:focus{border-color:#3ea6ff!important;box-shadow:0 0 0 1px rgba(62,166,255,.7)!important}
|
||||
body.dark-mode #deleteSelectedBtn,body.dark-mode #deleteAllBtn,body.dark-mode #deleteTrashSelectedBtn,#deleteFolderBtn,#confirmDeleteFolder{background-color:#b3261e!important;border-color:#b3261e!important;box-shadow:0 4px 10px rgba(0,0,0,.7)!important}
|
||||
body.dark-mode .folder-strip-container.folder-strip-mobile{background:var(--fr-surface-dark-2)!important;border:1px solid var(--fr-border-dark)!important}
|
||||
body.dark-mode #customToast{background:#212121!important;border:1px solid var(--fr-border-dark)!important;box-shadow:0 8px 20px rgba(0,0,0,.9)!important}
|
||||
body.dark-mode #fileSummary{color:var(--fr-muted-dark)!important}
|
||||
body.dark-mode #createMenu,body.dark-mode .user-dropdown .user-menu,body.dark-mode #fileContextMenu,body.dark-mode #folderContextMenu,body.dark-mode #folderManagerContextMenu,body.dark-mode #adminPanelModal .modal-content,body.dark-mode #userPermissionsModal .modal-content,body.dark-mode #userFlagsModal .modal-content,body.dark-mode #userGroupsModal .modal-content,body.dark-mode #userPanelModal .modal-content,body.dark-mode #groupAclModal .modal-content,body.dark-mode .editor-modal,body.dark-mode #filePreviewModal .modal-content,body.dark-mode #loginForm,body.dark-mode .editor-header,#clientPortalsModal .modal-content{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,body:not(.dark-mode) #clientPortalsModal .modal-content{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}
|
||||
302
public/js/adminPanelStyles.js
Normal file
@@ -0,0 +1,302 @@
|
||||
// Admin panel inline CSS moved out of adminPanel.js
|
||||
// This file is imported for its side effects only.
|
||||
|
||||
(function () {
|
||||
if (document.getElementById('adminPanelStyles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'adminPanelStyles';
|
||||
style.textContent = `
|
||||
/* Modal sizing */
|
||||
#adminPanelModal .modal-content {
|
||||
max-width: 1100px;
|
||||
width: 50%;
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
#adminPanelModal .modal-content {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
#adminPanelModal .modal-content {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal header */
|
||||
#adminPanelModal .modal-header {
|
||||
border-bottom: 1px solid rgba(0,0,0,0.15);
|
||||
padding: 0.75rem 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
#adminPanelModal .modal-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
#adminPanelModal .modal-title .admin-title-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0,0,0,0.12);
|
||||
background: rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
/* Modal body layout */
|
||||
#adminPanelModal .modal-body {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
#adminPanelModal .modal-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar nav */
|
||||
#adminPanelSidebar {
|
||||
width: 220px;
|
||||
max-width: 220px;
|
||||
padding-right: 0.75rem;
|
||||
border-right: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
#adminPanelSidebar {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.08);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
#adminPanelSidebar .nav {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
#adminPanelSidebar .nav-link {
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
color: #333;
|
||||
}
|
||||
#adminPanelSidebar .nav-link .material-icons {
|
||||
font-size: 1rem;
|
||||
}
|
||||
#adminPanelSidebar .nav-link.active {
|
||||
background: rgba(0, 123, 255, 0.08);
|
||||
border-color: rgba(0, 123, 255, 0.3);
|
||||
color: #0056b3;
|
||||
}
|
||||
#adminPanelSidebar .nav-link:hover {
|
||||
background: rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
#adminPanelContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-section-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.admin-section-title .material-icons {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.admin-section-subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(0,0,0,0.6);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-field-group {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.admin-field-group label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.admin-field-group small {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.admin-inline-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
background: rgba(0,0,0,0.03);
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
.admin-badge .material-icons {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.admin-table-sm {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.admin-table-sm th,
|
||||
.admin-table-sm td {
|
||||
padding: 0.35rem 0.4rem !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Switch alignment */
|
||||
.form-check.form-switch .form-check-input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Pro license textarea */
|
||||
#proLicenseInput {
|
||||
font-family: var(--filr-font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Pro info alert */
|
||||
#proLicenseStatus {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* Client portals */
|
||||
#clientPortalsBody .portal-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.04);
|
||||
}
|
||||
#clientPortalsBody .portal-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
#clientPortalsBody .portal-meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0,0,0,0.7);
|
||||
}
|
||||
#clientPortalsBody .portal-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Submissions list */
|
||||
#clientPortalsBody .portal-submissions {
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px dashed rgba(0,0,0,0.08);
|
||||
}
|
||||
#clientPortalsBody .portal-submissions-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.1rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
#clientPortalsBody .portal-submissions-empty {
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
#clientPortalsBody .portal-submissions-item {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
#clientPortalsBody .portal-submissions-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
#clientPortalsBody .portal-submissions-meta {
|
||||
opacity: 0.75;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark-mode #adminPanelModal .modal-content {
|
||||
background: #121212 !important;
|
||||
color: #f5f5f5 !important;
|
||||
border-color: rgba(255,255,255,0.15) !important;
|
||||
}
|
||||
.dark-mode #adminPanelModal .modal-header {
|
||||
border-bottom-color: rgba(255,255,255,0.15);
|
||||
}
|
||||
.dark-mode #adminPanelSidebar {
|
||||
border-right-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
.dark-mode #adminPanelSidebar .nav-link {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.dark-mode #adminPanelSidebar .nav-link:hover {
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
.dark-mode #adminPanelSidebar .nav-link.active {
|
||||
background: rgba(13,110,253,0.3);
|
||||
border-color: rgba(13,110,253,0.7);
|
||||
color: #fff;
|
||||
}
|
||||
.dark-mode .admin-section-subtitle {
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.dark-mode .admin-field-group small {
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.dark-mode .admin-badge {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
.dark-mode .admin-table-sm tbody tr:hover td {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.dark-mode #clientPortalsBody .portal-row {
|
||||
border-bottom-color: rgba(255,255,255,0.08);
|
||||
}
|
||||
.dark-mode #clientPortalsBody .portal-meta {
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
.dark-mode #clientPortalsBody .portal-submissions {
|
||||
border-top-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
.dark-mode #clientPortalsBody .portal-submissions-empty {
|
||||
color: rgba(255,255,255,0.5);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
@@ -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();
|
||||
|
||||
@@ -34,18 +34,19 @@ window.currentOIDCConfig = currentOIDCConfig;
|
||||
|
||||
|
||||
(function installToastFilter() {
|
||||
const isDemoHost = location.hostname.toLowerCase() === 'demo.filerise.net';
|
||||
|
||||
window.__FR_TOAST_FILTER__ = function (msgKeyOrText) {
|
||||
const isDemoMode = !!window.__FR_DEMO__;
|
||||
|
||||
// Suppress the nag while doing TOTP step-up
|
||||
if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' ||
|
||||
/please log in/i.test(String(msgKeyOrText)))) {
|
||||
return null; // suppress
|
||||
}
|
||||
|
||||
// Demo host
|
||||
if (isDemoHost && (msgKeyOrText === 'please_log_in_to_continue' ||
|
||||
/please log in/i.test(String(msgKeyOrText)))) {
|
||||
// Demo mode: swap login prompt for demo creds
|
||||
if (isDemoMode &&
|
||||
(msgKeyOrText === 'please_log_in_to_continue' ||
|
||||
/please log in/i.test(String(msgKeyOrText)))) {
|
||||
return "Demo site — use:\nUsername: demo\nPassword: demo";
|
||||
}
|
||||
|
||||
@@ -81,14 +82,16 @@ window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_requi
|
||||
// override showToast to suppress the "Please log in to continue." toast during TOTP
|
||||
|
||||
function showToast(msgKeyOrText, type) {
|
||||
const isDemoHost = window.location.hostname.toLowerCase() === "demo.filerise.net";
|
||||
const isDemoMode = !!window.__FR_DEMO__;
|
||||
|
||||
// If it's the pre-login prompt and we're on the demo site, show demo creds instead.
|
||||
if (isDemoHost) {
|
||||
// For the pre-login prompt in demo mode, show demo creds instead
|
||||
if (isDemoMode &&
|
||||
(msgKeyOrText === "please_log_in_to_continue" ||
|
||||
/please log in/i.test(String(msgKeyOrText)))) {
|
||||
return originalShowToast("Demo site — use: \nUsername: demo\nPassword: demo", 12000);
|
||||
}
|
||||
|
||||
// Don’t nag during pending TOTP, as you already had
|
||||
// Don’t nag during pending TOTP
|
||||
if (window.pendingTOTP && msgKeyOrText === "please_log_in_to_continue") {
|
||||
return;
|
||||
}
|
||||
@@ -97,11 +100,10 @@ function showToast(msgKeyOrText, type) {
|
||||
let msg = msgKeyOrText;
|
||||
try {
|
||||
const translated = t(msgKeyOrText);
|
||||
// If t() changed it or it's a key-like string, use the translation
|
||||
if (typeof translated === "string" && translated !== msgKeyOrText) {
|
||||
msg = translated;
|
||||
}
|
||||
} catch { /* if t() isn’t available here, just use the original */ }
|
||||
} catch { }
|
||||
|
||||
return originalShowToast(msg);
|
||||
}
|
||||
@@ -351,26 +353,8 @@ export async function updateAuthenticatedUI(data) {
|
||||
if (r) r.style.display = "none";
|
||||
}
|
||||
|
||||
// b) admin panel button only on demo.filerise.net
|
||||
if (data.isAdmin && window.location.hostname === "demo.filerise.net") {
|
||||
let a = document.getElementById("adminPanelBtn");
|
||||
if (!a) {
|
||||
a = document.createElement("button");
|
||||
a.id = "adminPanelBtn";
|
||||
a.classList.add("btn", "btn-info");
|
||||
a.setAttribute("data-i18n-title", "admin_panel");
|
||||
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
||||
insertAfter(a, document.getElementById("restoreFilesBtn"));
|
||||
a.addEventListener("click", openAdminPanel);
|
||||
}
|
||||
a.style.display = "block";
|
||||
} else {
|
||||
const a = document.getElementById("adminPanelBtn");
|
||||
if (a) a.style.display = "none";
|
||||
}
|
||||
|
||||
// c) user dropdown on non-demo
|
||||
if (window.location.hostname !== "demo.filerise.net") {
|
||||
{
|
||||
let dd = document.getElementById("userDropdown");
|
||||
|
||||
// choose icon *or* img
|
||||
@@ -866,6 +850,10 @@ function initAuth() {
|
||||
});
|
||||
document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal);
|
||||
document.getElementById("changePasswordBtn").addEventListener("click", function () {
|
||||
if (window.__FR_DEMO__) {
|
||||
showToast("Password changes are disabled on the public demo.");
|
||||
return;
|
||||
}
|
||||
document.getElementById("changePasswordModal").style.display = "block";
|
||||
document.getElementById("oldPassword").focus();
|
||||
});
|
||||
@@ -873,6 +861,10 @@ function initAuth() {
|
||||
document.getElementById("changePasswordModal").style.display = "none";
|
||||
});
|
||||
document.getElementById("saveNewPasswordBtn").addEventListener("click", function () {
|
||||
if (window.__FR_DEMO__) {
|
||||
showToast("Password changes are disabled on the public demo.");
|
||||
return;
|
||||
}
|
||||
const oldPassword = document.getElementById("oldPassword").value.trim();
|
||||
const newPassword = document.getElementById("newPassword").value.trim();
|
||||
const confirmPassword = document.getElementById("confirmPassword").value.trim();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -230,23 +230,47 @@ function showNoAccessEmptyState() {
|
||||
function renderBreadcrumbFragment(folderPath) {
|
||||
const frag = document.createDocumentFragment();
|
||||
const path = (typeof folderPath === 'string' && folderPath.length) ? folderPath : 'root';
|
||||
|
||||
// --- Always start with "Root" crumb ---
|
||||
const rootSpan = document.createElement('span');
|
||||
rootSpan.className = 'breadcrumb-link';
|
||||
rootSpan.dataset.folder = 'root';
|
||||
rootSpan.textContent = 'root';
|
||||
frag.appendChild(rootSpan);
|
||||
|
||||
if (path === 'root') {
|
||||
// You are in root: just "Root"
|
||||
return frag;
|
||||
}
|
||||
|
||||
// Separator after Root
|
||||
let sep = document.createElement('span');
|
||||
sep.className = 'file-breadcrumb-sep';
|
||||
sep.textContent = '›';
|
||||
frag.appendChild(sep);
|
||||
|
||||
// Now add the rest of the path normally (folder1, folder1/subA, etc.)
|
||||
const crumbs = path.split('/').filter(Boolean);
|
||||
let acc = '';
|
||||
|
||||
for (let i = 0; i < crumbs.length; i++) {
|
||||
const part = crumbs[i];
|
||||
acc = (i === 0) ? part : (acc + '/' + part);
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = 'breadcrumb-link';
|
||||
span.dataset.folder = acc;
|
||||
span.textContent = part;
|
||||
frag.appendChild(span);
|
||||
|
||||
if (i < crumbs.length - 1) {
|
||||
const sep = document.createElement('span');
|
||||
sep = document.createElement('span');
|
||||
sep.className = 'file-breadcrumb-sep';
|
||||
sep.textContent = '›';
|
||||
frag.appendChild(sep);
|
||||
}
|
||||
}
|
||||
|
||||
return frag;
|
||||
}
|
||||
export function updateBreadcrumbTitle(folder) {
|
||||
@@ -928,7 +952,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",
|
||||
@@ -330,7 +330,14 @@ const translations = {
|
||||
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
|
||||
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
|
||||
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
|
||||
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder."
|
||||
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
|
||||
"load_more_folders": "Load More Folders",
|
||||
"show_inline_folders": "Show folders as rows above files",
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
"modified": "Modified",
|
||||
"created": "Created",
|
||||
"owner": "Owner"
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
@@ -62,23 +62,43 @@ async function ensureToastReady() {
|
||||
}
|
||||
|
||||
function isDemoHost() {
|
||||
// Handles optional "www." just in case
|
||||
try {
|
||||
const cfg = window.__FR_SITE_CFG__ || {};
|
||||
if (typeof cfg.demoMode !== 'undefined') {
|
||||
return !!cfg.demoMode;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Fallback for older configs / direct demo host:
|
||||
return location.hostname.replace(/^www\./, '') === 'demo.filerise.net';
|
||||
}
|
||||
|
||||
function showLoginTip(message) {
|
||||
const tip = document.getElementById('fr-login-tip');
|
||||
if (!tip) return;
|
||||
tip.innerHTML = ''; // clear
|
||||
if (message) tip.append(document.createTextNode(message));
|
||||
if (location.hostname.replace(/^www\./, '') === 'demo.filerise.net') {
|
||||
const line = document.createElement('div'); line.style.marginTop = '6px';
|
||||
const mk = t => { const k = document.createElement('code'); k.textContent = t; return k; };
|
||||
line.append(document.createTextNode('Demo login — user: '), mk('demo'),
|
||||
document.createTextNode(' · pass: '), mk('demo'));
|
||||
tip.innerHTML = ''; // clear
|
||||
|
||||
if (message) {
|
||||
tip.append(document.createTextNode(message));
|
||||
}
|
||||
|
||||
if (isDemoHost()) {
|
||||
const line = document.createElement('div');
|
||||
line.style.marginTop = '6px';
|
||||
const mk = t => {
|
||||
const k = document.createElement('code');
|
||||
k.textContent = t;
|
||||
return k;
|
||||
};
|
||||
line.append(
|
||||
document.createTextNode('Demo login — user: '), mk('demo'),
|
||||
document.createTextNode(' · pass: '), mk('demo')
|
||||
);
|
||||
tip.append(line);
|
||||
}
|
||||
tip.style.display = 'block'; // reveal without shifting layout
|
||||
|
||||
tip.style.display = 'block';
|
||||
}
|
||||
|
||||
async function hideOverlaySmoothly(overlay) {
|
||||
@@ -225,6 +245,32 @@ window.__FR_FLAGS.entryStarted = window.__FR_FLAGS.entryStarted || false;
|
||||
return p.then(r => r.clone());
|
||||
};
|
||||
|
||||
// ---- Safe redirect helper (prevents open redirects) ----
|
||||
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const str = String(raw).trim();
|
||||
if (!str) return fallback;
|
||||
|
||||
const candidate = new URL(str, window.location.origin);
|
||||
|
||||
// Enforce same-origin
|
||||
if (candidate.origin !== window.location.origin) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Limit to http/https
|
||||
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Return relative URL
|
||||
return candidate.pathname + candidate.search + candidate.hash;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// Gentle toast normalizer (compatible with showToast(message, duration))
|
||||
const origToast = window.showToast;
|
||||
if (typeof origToast === 'function' && !origToast.__frWrapped) {
|
||||
@@ -526,11 +572,13 @@ function bindDarkMode() {
|
||||
const r = await fetch('/api/siteConfig.php', { credentials: 'include' });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
window.__FR_SITE_CFG__ = j || {};
|
||||
window.__FR_DEMO__ = !!(window.__FR_SITE_CFG__.demoMode);
|
||||
// Early pass: title + login options (skip touching <h1> to avoid flicker)
|
||||
applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' });
|
||||
return window.__FR_SITE_CFG__;
|
||||
} catch {
|
||||
window.__FR_SITE_CFG__ = {};
|
||||
window.__FR_DEMO__ = false;
|
||||
applySiteConfig({}, { phase: 'early' });
|
||||
return null;
|
||||
}
|
||||
@@ -883,6 +931,19 @@ function bindDarkMode() {
|
||||
});
|
||||
}
|
||||
function afterLogin() {
|
||||
// If index.html was opened with ?redirect=<url>, honor that first
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const raw = url.searchParams.get('redirect');
|
||||
const safe = sanitizeRedirect(raw, { fallback: null });
|
||||
if (safe) {
|
||||
window.location.href = safe;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore URL/param issues and fall back to normal behavior
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
(function poll() {
|
||||
checkAuth().then(({ authed }) => {
|
||||
|
||||
382
public/js/portal-login.js
Normal file
@@ -0,0 +1,382 @@
|
||||
// public/js/portal-login.js
|
||||
|
||||
// -------- URL helpers --------
|
||||
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const str = String(raw).trim();
|
||||
if (!str) return fallback;
|
||||
|
||||
// Resolve against current origin so relative URLs work
|
||||
const candidate = new URL(str, window.location.origin);
|
||||
|
||||
// 1) Must stay on the same origin
|
||||
if (candidate.origin !== window.location.origin) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// 2) Only allow http/https
|
||||
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Return a relative URL (prevents host changes)
|
||||
return candidate.pathname + candidate.search + candidate.hash;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function getRedirectTarget() {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const raw = url.searchParams.get('redirect');
|
||||
|
||||
// Default fallback: root
|
||||
let target = sanitizeRedirect(raw, { fallback: '/' });
|
||||
|
||||
// If there was no *usable* redirect but we have a portal slug,
|
||||
// send them back to that portal by default.
|
||||
if (!target || target === '/') {
|
||||
const slug = getPortalSlugFromUrl();
|
||||
if (slug) {
|
||||
target = sanitizeRedirect('/portal/' + encodeURIComponent(slug), { fallback: '/' });
|
||||
}
|
||||
}
|
||||
|
||||
return target || '/';
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
function getPortalSlugFromUrl() {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
// 1) Direct ?slug=portal-xxxxx on login page (if ever used)
|
||||
let slug = url.searchParams.get('slug');
|
||||
if (slug && slug.trim()) {
|
||||
console.log('portal-login: slug from top-level param =', slug.trim());
|
||||
return slug.trim();
|
||||
}
|
||||
|
||||
// 2) From redirect param: may be portal.html?slug=... or /portal/<slug>
|
||||
const redirect = url.searchParams.get('redirect');
|
||||
if (redirect) {
|
||||
console.log('portal-login: raw redirect param =', redirect);
|
||||
|
||||
try {
|
||||
const redirectUrl = new URL(redirect, window.location.origin);
|
||||
|
||||
// 2a) ?slug=... in redirect
|
||||
const innerSlug = redirectUrl.searchParams.get('slug');
|
||||
if (innerSlug && innerSlug.trim()) {
|
||||
console.log('portal-login: slug from redirect URL =', innerSlug.trim());
|
||||
return innerSlug.trim();
|
||||
}
|
||||
|
||||
// 2b) Pretty path /portal/<slug> in redirect
|
||||
const pathMatch = redirectUrl.pathname.match(/\/portal\/([^\/?#]+)/i);
|
||||
if (pathMatch && pathMatch[1]) {
|
||||
const fromPath = pathMatch[1].trim();
|
||||
console.log('portal-login: slug from redirect path =', fromPath);
|
||||
return fromPath;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('portal-login: failed to parse redirect URL', err);
|
||||
}
|
||||
|
||||
// 2c) Fallback regex on redirect string
|
||||
const m = redirect.match(/[?&]slug=([^&]+)/);
|
||||
if (m && m[1]) {
|
||||
const decoded = decodeURIComponent(m[1]).trim();
|
||||
console.log('portal-login: slug from redirect regex =', decoded);
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Legacy fallback on current query string
|
||||
const qs = window.location.search || '';
|
||||
const m2 = qs.match(/[?&]slug=([^&]+)/);
|
||||
if (m2 && m2[1]) {
|
||||
const decoded2 = decodeURIComponent(m2[1]).trim();
|
||||
console.log('portal-login: slug from own query regex =', decoded2);
|
||||
return decoded2;
|
||||
}
|
||||
|
||||
console.log('portal-login: no slug found');
|
||||
return '';
|
||||
} catch (err) {
|
||||
console.warn('portal-login: getPortalSlugFromUrl error', err);
|
||||
const qs = window.location.search || '';
|
||||
const m = qs.match(/[?&]slug=([^&]+)/);
|
||||
return m && m[1] ? decodeURIComponent(m[1]).trim() : '';
|
||||
}
|
||||
}
|
||||
|
||||
// --- CSRF helpers (same pattern as portal.js) ---
|
||||
function setCsrfToken(token) {
|
||||
if (!token) return;
|
||||
window.csrfToken = token;
|
||||
try {
|
||||
localStorage.setItem('csrf', token);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
let meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.name = 'csrf-token';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = token;
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
return (
|
||||
window.csrfToken ||
|
||||
(document.querySelector('meta[name="csrf-token"]')?.content) ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCsrfToken() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/token.php', {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const hdr = res.headers.get('X-CSRF-Token');
|
||||
if (hdr) setCsrfToken(hdr);
|
||||
|
||||
let body = {};
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
const token = body.csrf_token || getCsrfToken();
|
||||
setCsrfToken(token);
|
||||
} catch (e) {
|
||||
console.warn('portal-login: failed to load CSRF token', e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- UI helpers ---
|
||||
function showError(msg) {
|
||||
const box = document.getElementById('portalLoginError');
|
||||
if (!box) return;
|
||||
box.textContent = msg || 'Login failed.';
|
||||
box.classList.add('show');
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
const box = document.getElementById('portalLoginError');
|
||||
if (!box) return;
|
||||
box.textContent = '';
|
||||
box.classList.remove('show');
|
||||
}
|
||||
|
||||
// -------- Portal meta (title + accent) --------
|
||||
async function fetchPortalMeta(slug) {
|
||||
if (!slug) return null;
|
||||
console.log('portal-login: calling publicMeta.php for slug', slug);
|
||||
try {
|
||||
const res = await fetch(
|
||||
'/api/pro/portals/publicMeta.php?slug=' + encodeURIComponent(slug),
|
||||
{ method: 'GET', credentials: 'include' }
|
||||
);
|
||||
const text = await res.text();
|
||||
let data = {};
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
if (!res.ok || !data || !data.success || !data.portal) {
|
||||
console.warn('portal-login: publicMeta not ok', res.status, data);
|
||||
return null;
|
||||
}
|
||||
return data.portal;
|
||||
} catch (e) {
|
||||
console.warn('portal-login: failed to load portal meta', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyPortalBranding(portal) {
|
||||
if (!portal) return;
|
||||
|
||||
const title =
|
||||
(portal.title && portal.title.trim()) ||
|
||||
portal.label ||
|
||||
portal.slug ||
|
||||
'Client portal';
|
||||
|
||||
const headingEl = document.getElementById('portalLoginTitle');
|
||||
const subtitleEl = document.getElementById('portalLoginSubtitle');
|
||||
const footerEl = document.getElementById('portalLoginFooter');
|
||||
|
||||
if (headingEl) {
|
||||
headingEl.textContent = 'Sign in to ' + title;
|
||||
}
|
||||
if (subtitleEl) {
|
||||
subtitleEl.textContent = 'to access this client portal';
|
||||
}
|
||||
|
||||
// Footer text from portal metadata, if provided
|
||||
if (footerEl) {
|
||||
const ft = (portal.footerText && portal.footerText.trim()) || '';
|
||||
if (ft) {
|
||||
footerEl.textContent = ft;
|
||||
footerEl.style.display = 'block';
|
||||
} else {
|
||||
footerEl.textContent = '';
|
||||
footerEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Document title
|
||||
try {
|
||||
document.title = 'Sign in – ' + title;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Accent: portal brandColor -> CSS var
|
||||
const brand = portal.brandColor && portal.brandColor.trim();
|
||||
if (brand) {
|
||||
document.documentElement.style.setProperty('--portal-accent', brand);
|
||||
}
|
||||
|
||||
// Reapply card/button accent after we know portal color
|
||||
applyAccentFromTheme();
|
||||
}
|
||||
|
||||
// --- Accent (card + button) ---
|
||||
function applyAccentFromTheme() {
|
||||
const card = document.querySelector('.portal-login-card');
|
||||
const btn = document.getElementById('portalLoginSubmit');
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
|
||||
// Prefer per-portal accent if present
|
||||
let accent = rootStyles.getPropertyValue('--portal-accent').trim();
|
||||
if (!accent) {
|
||||
accent = rootStyles.getPropertyValue('--filr-accent-500').trim() || '#0b5ed7';
|
||||
}
|
||||
|
||||
if (card) {
|
||||
card.style.borderTop = `3px solid ${accent}`;
|
||||
}
|
||||
if (btn) {
|
||||
btn.style.backgroundColor = accent;
|
||||
btn.style.borderColor = accent;
|
||||
}
|
||||
|
||||
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||||
if (metaTheme) {
|
||||
metaTheme.setAttribute('content', accent);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Login call (JSON -> auth.php) ---
|
||||
async function doLogin(username, password) {
|
||||
const csrf = getCsrfToken() || '';
|
||||
|
||||
const payload = {
|
||||
username,
|
||||
password
|
||||
};
|
||||
if (csrf) {
|
||||
payload.csrf_token = csrf;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/auth/auth.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = body.error || body.message || text || 'Login failed.';
|
||||
const err = new Error(msg);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (body.success === false || body.error || body.logged_in === false) {
|
||||
throw new Error(body.error || 'Invalid username or password.');
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const form = document.getElementById('portalLoginForm');
|
||||
const userEl = document.getElementById('portalLoginUser');
|
||||
const passEl = document.getElementById('portalLoginPass');
|
||||
const btn = document.getElementById('portalLoginSubmit');
|
||||
|
||||
// Accent first (fallback to global accent)
|
||||
applyAccentFromTheme();
|
||||
|
||||
// Try to load portal meta (title + brand color) using slug
|
||||
const slug = getPortalSlugFromUrl();
|
||||
console.log('portal-login: computed slug =', slug);
|
||||
if (slug) {
|
||||
fetchPortalMeta(slug).then(portal => {
|
||||
if (portal) {
|
||||
console.log('portal-login: got portal meta for', slug, portal);
|
||||
applyPortalBranding(portal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-load CSRF (for auth.php)
|
||||
loadCsrfToken().catch(() => {});
|
||||
|
||||
if (!form || !userEl || !passEl || !btn) return;
|
||||
|
||||
// Focus username
|
||||
userEl.focus();
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
|
||||
const username = userEl.value.trim();
|
||||
const password = passEl.value;
|
||||
|
||||
if (!username || !password) {
|
||||
showError('Username and password are required');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Signing in…';
|
||||
|
||||
try {
|
||||
await doLogin(username, password);
|
||||
const target = getRedirectTarget();
|
||||
window.location.href = target;
|
||||
} catch (err) {
|
||||
console.error('portal-login: auth failed', err);
|
||||
showError(err.message || 'Login failed. Please try again.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Sign in';
|
||||
}
|
||||
});
|
||||
});
|
||||
716
public/js/portal.js
Normal file
@@ -0,0 +1,716 @@
|
||||
// public/js/portal.js
|
||||
// Standalone client portal logic – no imports from main app JS to avoid DOM coupling.
|
||||
|
||||
let portal = null;
|
||||
let portalFormDone = false;
|
||||
|
||||
// --- Portal helpers: folder + download flag -----------------
|
||||
function portalFolder() {
|
||||
if (!portal) return 'root';
|
||||
return portal.folder || portal.targetFolder || portal.path || 'root';
|
||||
}
|
||||
|
||||
function portalCanDownload() {
|
||||
if (!portal) return false;
|
||||
|
||||
// Prefer explicit flags if present
|
||||
if (typeof portal.allowDownload !== 'undefined') {
|
||||
return !!portal.allowDownload;
|
||||
}
|
||||
if (typeof portal.allowDownloads !== 'undefined') {
|
||||
return !!portal.allowDownloads;
|
||||
}
|
||||
|
||||
// Fallback: uploadOnly = true => no downloads
|
||||
if (typeof portal.uploadOnly !== 'undefined') {
|
||||
return !portal.uploadOnly;
|
||||
}
|
||||
|
||||
// Default: allow downloads
|
||||
return true;
|
||||
}
|
||||
|
||||
// ----------------- DOM helpers / status -----------------
|
||||
function qs(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function setStatus(msg, isError = false) {
|
||||
const el = qs('portalStatus');
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
el.classList.toggle('text-danger', !!isError);
|
||||
if (!isError) {
|
||||
el.classList.add('text-muted');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- Form submit -----------------
|
||||
async function submitPortalForm(slug, formData) {
|
||||
const payload = {
|
||||
slug,
|
||||
form: formData
|
||||
};
|
||||
const headers = { 'X-CSRF-Token': getCsrfToken() || '' };
|
||||
const res = await sendRequest('/api/pro/portals/submitForm.php', 'POST', payload, headers);
|
||||
if (!res || !res.success) {
|
||||
throw new Error((res && res.error) || 'Error saving form.');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- Toast -----------------
|
||||
function showToast(message) {
|
||||
const toast = document.getElementById('customToast');
|
||||
if (!toast) {
|
||||
console.warn('Toast:', message);
|
||||
return;
|
||||
}
|
||||
toast.textContent = message;
|
||||
toast.style.display = 'block';
|
||||
// Force reflow
|
||||
void toast.offsetWidth;
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
toast.style.display = 'none';
|
||||
}, 200);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
// ----------------- Fetch wrapper -----------------
|
||||
async function sendRequest(url, method = 'GET', data = null, customHeaders = {}) {
|
||||
const options = {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: { ...customHeaders }
|
||||
};
|
||||
|
||||
if (data && !(data instanceof FormData)) {
|
||||
options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
|
||||
options.body = JSON.stringify(data);
|
||||
} else if (data instanceof FormData) {
|
||||
options.body = data;
|
||||
}
|
||||
|
||||
const res = await fetch(url, options);
|
||||
const text = await res.text();
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw payload;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ----------------- Portal form wiring -----------------
|
||||
function setupPortalForm(slug) {
|
||||
const formSection = qs('portalFormSection');
|
||||
const uploadSection = qs('portalUploadSection');
|
||||
|
||||
if (!portal || !portal.requireForm) {
|
||||
if (formSection) formSection.style.display = 'none';
|
||||
if (uploadSection) uploadSection.style.opacity = '1';
|
||||
return;
|
||||
}
|
||||
|
||||
const key = 'portalFormDone:' + slug;
|
||||
if (sessionStorage.getItem(key) === '1') {
|
||||
portalFormDone = true;
|
||||
if (formSection) formSection.style.display = 'none';
|
||||
if (uploadSection) uploadSection.style.opacity = '1';
|
||||
return;
|
||||
}
|
||||
|
||||
portalFormDone = false;
|
||||
if (formSection) formSection.style.display = 'block';
|
||||
if (uploadSection) uploadSection.style.opacity = '0.5';
|
||||
|
||||
const nameEl = qs('portalFormName');
|
||||
const emailEl = qs('portalFormEmail');
|
||||
const refEl = qs('portalFormReference');
|
||||
const notesEl = qs('portalFormNotes');
|
||||
const submitBtn = qs('portalFormSubmit');
|
||||
|
||||
const fd = portal.formDefaults || {};
|
||||
|
||||
if (nameEl && fd.name && !nameEl.value) {
|
||||
nameEl.value = fd.name;
|
||||
}
|
||||
if (emailEl && fd.email && !emailEl.value) {
|
||||
emailEl.value = fd.email;
|
||||
} else if (emailEl && portal.clientEmail && !emailEl.value) {
|
||||
// fallback to clientEmail
|
||||
emailEl.value = portal.clientEmail;
|
||||
}
|
||||
if (refEl && fd.reference && !refEl.value) {
|
||||
refEl.value = fd.reference;
|
||||
}
|
||||
if (notesEl && fd.notes && !notesEl.value) {
|
||||
notesEl.value = fd.notes;
|
||||
}
|
||||
|
||||
if (!submitBtn) return;
|
||||
|
||||
submitBtn.onclick = async () => {
|
||||
const name = nameEl ? nameEl.value.trim() : '';
|
||||
const email = emailEl ? emailEl.value.trim() : '';
|
||||
const reference = refEl ? refEl.value.trim() : '';
|
||||
const notes = notesEl ? notesEl.value.trim() : '';
|
||||
|
||||
const req = portal.formRequired || {};
|
||||
const missing = [];
|
||||
|
||||
if (req.name && !name) missing.push('name');
|
||||
if (req.email && !email) missing.push('email');
|
||||
if (req.reference && !reference) missing.push('reference');
|
||||
if (req.notes && !notes) missing.push('notes');
|
||||
|
||||
if (missing.length) {
|
||||
showToast('Please fill in: ' + missing.join(', ') + '.');
|
||||
return;
|
||||
}
|
||||
|
||||
// default behavior when no specific required flags:
|
||||
if (!req.name && !req.email && !req.reference && !req.notes) {
|
||||
if (!name && !email) {
|
||||
showToast('Please provide at least a name or email.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await submitPortalForm(slug, { name, email, reference, notes });
|
||||
portalFormDone = true;
|
||||
sessionStorage.setItem(key, '1');
|
||||
if (formSection) formSection.style.display = 'none';
|
||||
if (uploadSection) uploadSection.style.opacity = '1';
|
||||
showToast('Thank you. You can now upload files.');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Error saving your info. Please try again.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------- CSRF helpers -----------------
|
||||
function setCsrfToken(token) {
|
||||
if (!token) return;
|
||||
window.csrfToken = token;
|
||||
try {
|
||||
localStorage.setItem('csrf', token);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
let meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.name = 'csrf-token';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = token;
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
return window.csrfToken || (document.querySelector('meta[name="csrf-token"]')?.content) || '';
|
||||
}
|
||||
|
||||
async function loadCsrfToken() {
|
||||
const res = await fetch('/api/auth/token.php', { method: 'GET', credentials: 'include' });
|
||||
|
||||
const hdr = res.headers.get('X-CSRF-Token');
|
||||
if (hdr) setCsrfToken(hdr);
|
||||
|
||||
let body = {};
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
const token = body.csrf_token || getCsrfToken();
|
||||
setCsrfToken(token);
|
||||
}
|
||||
|
||||
// ----------------- Auth -----------------
|
||||
async function ensureAuthenticated() {
|
||||
try {
|
||||
const data = await sendRequest('/api/auth/checkAuth.php', 'GET');
|
||||
if (!data || !data.username) {
|
||||
// redirect to main UI/login; after login, user can re-open portal link
|
||||
const target = encodeURIComponent(window.location.href);
|
||||
window.location.href = '/portal-login.html?redirect=' + target;
|
||||
return null;
|
||||
}
|
||||
const lbl = qs('portalUserLabel');
|
||||
if (lbl) {
|
||||
lbl.textContent = data.username || '';
|
||||
}
|
||||
return data;
|
||||
} catch (e) {
|
||||
const target = encodeURIComponent(window.location.href);
|
||||
window.location.href = '/portal-login.html?redirect=' + target;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- Portal fetch + render -----------------
|
||||
async function fetchPortal(slug) {
|
||||
setStatus('Loading portal details…');
|
||||
try {
|
||||
const data = await sendRequest('/api/pro/portals/get.php?slug=' + encodeURIComponent(slug), 'GET');
|
||||
if (!data || !data.success || !data.portal) {
|
||||
throw new Error((data && data.error) || 'Portal not found.');
|
||||
}
|
||||
portal = data.portal;
|
||||
return portal;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setStatus('This portal could not be found or is no longer available.', true);
|
||||
showToast('Portal not found or expired.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPortalInfo() {
|
||||
if (!portal) return;
|
||||
const titleEl = qs('portalTitle');
|
||||
const descEl = qs('portalDescription');
|
||||
const subtitleEl = qs('portalSubtitle');
|
||||
const brandEl = document.getElementById('portalBrandHeading');
|
||||
const footerEl = document.getElementById('portalFooter');
|
||||
const drop = qs('portalDropzone');
|
||||
const card = document.querySelector('.portal-card');
|
||||
const formBtn = qs('portalFormSubmit');
|
||||
const refreshBtn = qs('portalRefreshBtn');
|
||||
const filesSection = qs('portalFilesSection');
|
||||
|
||||
const heading = portal.title && portal.title.trim()
|
||||
? portal.title.trim()
|
||||
: (portal.label || portal.slug || 'Client portal');
|
||||
|
||||
if (titleEl) titleEl.textContent = heading;
|
||||
if (brandEl) brandEl.textContent = heading;
|
||||
|
||||
if (descEl) {
|
||||
if (portal.introText && portal.introText.trim()) {
|
||||
descEl.textContent = portal.introText.trim();
|
||||
} else {
|
||||
const folder = portalFolder();
|
||||
descEl.textContent = 'Files you upload here go directly into: ' + folder;
|
||||
}
|
||||
}
|
||||
|
||||
if (subtitleEl) {
|
||||
const parts = [];
|
||||
if (portal.uploadOnly) parts.push('upload only');
|
||||
if (portalCanDownload()) parts.push('download allowed');
|
||||
subtitleEl.textContent = parts.length ? parts.join(' • ') : '';
|
||||
}
|
||||
|
||||
if (footerEl) {
|
||||
footerEl.textContent = portal.footerText && portal.footerText.trim()
|
||||
? portal.footerText.trim()
|
||||
: '';
|
||||
}
|
||||
|
||||
const color = portal.brandColor && portal.brandColor.trim();
|
||||
if (color) {
|
||||
// expose brand color as a CSS variable for gallery styling
|
||||
document.documentElement.style.setProperty('--portal-accent', color);
|
||||
|
||||
if (drop) {
|
||||
drop.style.borderColor = color;
|
||||
}
|
||||
if (card) {
|
||||
card.style.borderTop = '3px solid ' + color;
|
||||
}
|
||||
if (formBtn) {
|
||||
formBtn.style.backgroundColor = color;
|
||||
formBtn.style.borderColor = color;
|
||||
}
|
||||
if (refreshBtn) {
|
||||
refreshBtn.style.borderColor = color;
|
||||
refreshBtn.style.color = color;
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide files section based on download capability
|
||||
if (filesSection) {
|
||||
filesSection.style.display = portalCanDownload() ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- File helpers for gallery -----------------
|
||||
function formatFileSizeLabel(f) {
|
||||
// API currently returns f.size as a human-readable string, so prefer that
|
||||
if (f && f.size) return f.size;
|
||||
return '';
|
||||
}
|
||||
|
||||
function fileExtLabel(name) {
|
||||
if (!name) return 'FILE';
|
||||
const parts = name.split('.');
|
||||
if (parts.length < 2) return 'FILE';
|
||||
const ext = parts.pop().trim().toUpperCase();
|
||||
if (!ext) return 'FILE';
|
||||
return ext.length <= 4 ? ext : ext.slice(0, 4);
|
||||
}
|
||||
|
||||
function isImageName(name) {
|
||||
if (!name) return false;
|
||||
return /\.(jpe?g|png|gif|bmp|webp|svg)$/i.test(name);
|
||||
}
|
||||
|
||||
// ----------------- Load files for portal gallery -----------------
|
||||
async function loadPortalFiles() {
|
||||
if (!portal || !portalCanDownload()) return;
|
||||
|
||||
const listEl = qs('portalFilesList');
|
||||
if (!listEl) return;
|
||||
|
||||
listEl.innerHTML = '<div class="text-muted" style="padding:4px 0;">Loading files…</div>';
|
||||
|
||||
try {
|
||||
const folder = portalFolder();
|
||||
const data = await sendRequest('/api/file/getFileList.php?folder=' + encodeURIComponent(folder), 'GET');
|
||||
if (!data || data.error) {
|
||||
const msg = (data && data.error) ? data.error : 'Error loading files.';
|
||||
listEl.innerHTML = '<div class="text-danger" style="padding:4px 0;">' + msg + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize files: handle both array and object-return shapes
|
||||
let files = [];
|
||||
if (Array.isArray(data.files)) {
|
||||
files = data.files;
|
||||
} else if (data.files && typeof data.files === 'object') {
|
||||
files = Object.entries(data.files).map(([name, meta]) => {
|
||||
const f = meta || {};
|
||||
f.name = name;
|
||||
return f;
|
||||
});
|
||||
}
|
||||
|
||||
if (!files.length) {
|
||||
listEl.innerHTML = '<div class="text-muted" style="padding:4px 0;">No files in this portal yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const accent = portal.brandColor && portal.brandColor.trim();
|
||||
|
||||
listEl.innerHTML = '';
|
||||
listEl.classList.add('portal-files-grid'); // gallery layout
|
||||
|
||||
const MAX = 24;
|
||||
const slice = files.slice(0, MAX);
|
||||
|
||||
slice.forEach(f => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'portal-file-card';
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'portal-file-card-icon';
|
||||
|
||||
const main = document.createElement('div');
|
||||
main.className = 'portal-file-card-main';
|
||||
|
||||
const nameEl = document.createElement('div');
|
||||
nameEl.className = 'portal-file-card-name';
|
||||
nameEl.textContent = f.name || 'Unnamed file';
|
||||
|
||||
const metaEl = document.createElement('div');
|
||||
metaEl.className = 'portal-file-card-meta text-muted';
|
||||
metaEl.textContent = formatFileSizeLabel(f);
|
||||
|
||||
main.appendChild(nameEl);
|
||||
main.appendChild(metaEl);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'portal-file-card-actions';
|
||||
|
||||
// Thumbnail vs extension badge
|
||||
const fname = f.name || '';
|
||||
const folder = portalFolder();
|
||||
|
||||
if (isImageName(fname)) {
|
||||
const thumbUrl =
|
||||
'/api/file/download.php?folder=' +
|
||||
encodeURIComponent(folder) +
|
||||
'&file=' + encodeURIComponent(fname) +
|
||||
'&inline=1&t=' + Date.now();
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = thumbUrl;
|
||||
img.alt = fname;
|
||||
// 🔧 constrain image so it doesn't fill the whole list
|
||||
img.style.maxWidth = '100%';
|
||||
img.style.maxHeight = '120px';
|
||||
img.style.objectFit = 'cover';
|
||||
img.style.display = 'block';
|
||||
img.style.borderRadius = '6px';
|
||||
|
||||
icon.appendChild(img);
|
||||
} else {
|
||||
icon.textContent = fileExtLabel(fname);
|
||||
}
|
||||
|
||||
if (accent) {
|
||||
icon.style.borderColor = accent;
|
||||
}
|
||||
|
||||
if (portalCanDownload()) {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/api/file/download.php?folder=' +
|
||||
encodeURIComponent(folder) +
|
||||
'&file=' + encodeURIComponent(fname);
|
||||
a.textContent = 'Download';
|
||||
a.className = 'portal-file-card-download';
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
actions.appendChild(a);
|
||||
}
|
||||
|
||||
card.appendChild(icon);
|
||||
card.appendChild(main);
|
||||
card.appendChild(actions);
|
||||
|
||||
listEl.appendChild(card);
|
||||
});
|
||||
|
||||
if (files.length > MAX) {
|
||||
const more = document.createElement('div');
|
||||
more.className = 'portal-files-more text-muted';
|
||||
more.textContent = 'And ' + (files.length - MAX) + ' more…';
|
||||
listEl.appendChild(more);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
listEl.innerHTML = '<div class="text-danger" style="padding:4px 0;">Error loading files.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- Upload -----------------
|
||||
async function uploadFiles(fileList) {
|
||||
if (!portal || !fileList || !fileList.length) return;
|
||||
if (portal.requireForm && !portalFormDone) {
|
||||
showToast('Please fill in your details before uploading.');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(fileList);
|
||||
const folder = portalFolder();
|
||||
|
||||
setStatus('Uploading ' + files.length + ' file(s)…');
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const form = new FormData();
|
||||
|
||||
const csrf = getCsrfToken() || '';
|
||||
|
||||
// Match main upload.js
|
||||
form.append('file[]', file);
|
||||
form.append('folder', folder);
|
||||
if (csrf) {
|
||||
form.append('upload_token', csrf); // legacy alias, but your controller supports it
|
||||
}
|
||||
|
||||
let retried = false;
|
||||
while (true) {
|
||||
try {
|
||||
const resp = await fetch('/api/upload/upload.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf || ''
|
||||
},
|
||||
body: form
|
||||
});
|
||||
|
||||
const text = await resp.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
|
||||
if (data && data.csrf_expired && data.csrf_token) {
|
||||
setCsrfToken(data.csrf_token);
|
||||
if (!retried) {
|
||||
retried = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resp.ok || (data && data.error)) {
|
||||
failureCount++;
|
||||
console.error('Upload error:', data || text);
|
||||
} else {
|
||||
successCount++;
|
||||
}
|
||||
break;
|
||||
} catch (e) {
|
||||
console.error('Upload error:', e);
|
||||
failureCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount && !failureCount) {
|
||||
setStatus('Uploaded ' + successCount + ' file(s).');
|
||||
showToast('Upload complete.');
|
||||
} else if (successCount && failureCount) {
|
||||
setStatus('Uploaded ' + successCount + ' file(s), ' + failureCount + ' failed.', true);
|
||||
showToast('Some files failed to upload.');
|
||||
} else {
|
||||
setStatus('Upload failed.', true);
|
||||
showToast('Upload failed.');
|
||||
}
|
||||
|
||||
if (portalCanDownload()) {
|
||||
loadPortalFiles();
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- Upload UI wiring -----------------
|
||||
function wireUploadUI() {
|
||||
const drop = qs('portalDropzone');
|
||||
const input = qs('portalFileInput');
|
||||
const refreshBtn = qs('portalRefreshBtn');
|
||||
|
||||
if (drop && input) {
|
||||
drop.addEventListener('click', () => input.click());
|
||||
|
||||
input.addEventListener('change', (e) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length) {
|
||||
uploadFiles(files);
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(ev => {
|
||||
drop.addEventListener(ev, e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
drop.classList.add('dragover');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(ev => {
|
||||
drop.addEventListener(ev, e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
drop.classList.remove('dragover');
|
||||
});
|
||||
});
|
||||
|
||||
drop.addEventListener('drop', e => {
|
||||
const dt = e.dataTransfer;
|
||||
if (!dt || !dt.files || !dt.files.length) return;
|
||||
uploadFiles(dt.files);
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
loadPortalFiles();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- Slug + init -----------------
|
||||
function getPortalSlugFromUrl() {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
// 1) Normal case: slug is directly in query (?slug=portal-xxxxx)
|
||||
let slug = url.searchParams.get('slug');
|
||||
if (slug && slug.trim()) {
|
||||
return slug.trim();
|
||||
}
|
||||
|
||||
// 2) Pretty URL: /portal/<slug>
|
||||
// e.g. /portal/portal-h46ozd
|
||||
const pathMatch = url.pathname.match(/\/portal\/([^\/?#]+)/i);
|
||||
if (pathMatch && pathMatch[1]) {
|
||||
return pathMatch[1].trim();
|
||||
}
|
||||
|
||||
// 3) Fallback: slug inside redirect param
|
||||
// e.g. ?redirect=/portal.html?slug=portal-h46ozd
|
||||
const redirect = url.searchParams.get('redirect');
|
||||
if (redirect) {
|
||||
try {
|
||||
const redirectUrl = new URL(redirect, window.location.origin);
|
||||
const innerSlug = redirectUrl.searchParams.get('slug');
|
||||
if (innerSlug && innerSlug.trim()) {
|
||||
return innerSlug.trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
const m = redirect.match(/[?&]slug=([^&]+)/);
|
||||
if (m && m[1]) {
|
||||
return decodeURIComponent(m[1]).trim();
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Final fallback: old regex on our own query string
|
||||
const qs = window.location.search || '';
|
||||
const m2 = qs.match(/[?&]slug=([^&]+)/);
|
||||
return m2 && m2[1] ? decodeURIComponent(m2[1]).trim() : '';
|
||||
} catch {
|
||||
const qs = window.location.search || '';
|
||||
const m = qs.match(/[?&]slug=([^&]+)/);
|
||||
return m && m[1] ? decodeURIComponent(m[1]).trim() : '';
|
||||
}
|
||||
}
|
||||
|
||||
async function initPortal() {
|
||||
const slug = getPortalSlugFromUrl();
|
||||
if (!slug) {
|
||||
setStatus('Missing portal slug.', true);
|
||||
showToast('Portal slug missing in URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadCsrfToken();
|
||||
} catch (e) {
|
||||
console.warn('CSRF load failed (may be fine if unauthenticated yet).', e);
|
||||
}
|
||||
|
||||
const auth = await ensureAuthenticated();
|
||||
if (!auth) return;
|
||||
|
||||
const p = await fetchPortal(slug);
|
||||
if (!p) return;
|
||||
|
||||
renderPortalInfo();
|
||||
setupPortalForm(slug);
|
||||
wireUploadUI();
|
||||
|
||||
if (portalCanDownload()) {
|
||||
loadPortalFiles();
|
||||
}
|
||||
|
||||
setStatus('Ready.');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initPortal().catch(err => {
|
||||
console.error(err);
|
||||
setStatus('Unexpected error initializing portal.', true);
|
||||
showToast('Unexpected error loading portal.');
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v1.9.10';
|
||||
window.APP_VERSION = 'v2.0.2';
|
||||
|
||||
146
public/portal-login.html
Normal file
@@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Sign in – Client Portal</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
|
||||
<!-- Favicons / assets -->
|
||||
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
|
||||
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
|
||||
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||
|
||||
<!-- CSS (reuse main app look) -->
|
||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||
|
||||
<!-- Version stamp -->
|
||||
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
|
||||
|
||||
<!-- Portal login JS -->
|
||||
<script type="module" src="/js/portal-login.js?v={{APP_QVER}}"></script>
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--pre-bg, #f4f4f7);
|
||||
}
|
||||
.portal-login-wrapper {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 16px;
|
||||
}
|
||||
.portal-login-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||
padding: 20px 22px 18px;
|
||||
background: #fff;
|
||||
}
|
||||
[data-theme="dark"] .portal-login-card {
|
||||
background: #1f2933;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.portal-login-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.portal-login-header img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.portal-login-title {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.portal-login-subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
[data-theme="dark"] .portal-login-subtitle {
|
||||
color: #9ca3af;
|
||||
}
|
||||
#portalLoginError {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 8px;
|
||||
display: none;
|
||||
}
|
||||
#portalLoginError.show {
|
||||
display: block;
|
||||
}
|
||||
.portal-login-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||
padding: 20px 22px 18px;
|
||||
background: #fff;
|
||||
border-top: 3px solid var(--filr-accent-500, #0b5ed7);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="light">
|
||||
<div class="portal-login-wrapper">
|
||||
<div class="portal-login-card">
|
||||
<div class="portal-login-header">
|
||||
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise">
|
||||
<div>
|
||||
<div id="portalLoginTitle" class="portal-login-title">
|
||||
Sign in to Client Portal
|
||||
</div>
|
||||
<div id="portalLoginSubtitle" class="portal-login-subtitle">
|
||||
to access this client portal
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="portalLoginError" class="alert alert-danger"></div>
|
||||
|
||||
<form id="portalLoginForm" novalidate>
|
||||
<div class="form-group">
|
||||
<label for="portalLoginUser">Username or email</label>
|
||||
<input type="text"
|
||||
class="form-control form-control-sm"
|
||||
id="portalLoginUser"
|
||||
autocomplete="username"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="portalLoginPass">Password</label>
|
||||
<input type="password"
|
||||
class="form-control form-control-sm"
|
||||
id="portalLoginPass"
|
||||
autocomplete="current-password"
|
||||
required>
|
||||
</div>
|
||||
<button type="submit"
|
||||
id="portalLoginSubmit"
|
||||
class="btn btn-primary btn-sm btn-block">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<small id="portalLoginHint"
|
||||
class="text-muted d-block mt-2"
|
||||
style="font-size:0.75rem;">
|
||||
You’ll be sent back to the portal automatically after signing in.
|
||||
</small>
|
||||
|
||||
<small id="portalLoginFooter"
|
||||
class="text-muted d-block mt-1"
|
||||
style="font-size:0.7rem; display:none;">
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
362
public/portal.html
Normal file
@@ -0,0 +1,362 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<style id="pretheme-css">
|
||||
html, body, #portalRoot { background: var(--pre-bg,#ffffff) !important; }
|
||||
</style>
|
||||
|
||||
|
||||
<head>
|
||||
<style>
|
||||
:root {
|
||||
--portal-accent: #0b5ed7;
|
||||
}
|
||||
|
||||
.portal-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.portal-card {
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||
padding: 20px 20px 16px;
|
||||
}
|
||||
.portal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.portal-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.portal-logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.portal-dropzone {
|
||||
border: 2px dashed rgba(0,0,0,0.2);
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.portal-dropzone.dragover {
|
||||
border-color: var(--portal-accent);
|
||||
background: rgba(11,94,215,0.06);
|
||||
}
|
||||
|
||||
/* Files list container (scrollable) */
|
||||
.portal-files-list {
|
||||
margin-top: 14px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* NEW: grid-style gallery inside the list */
|
||||
.portal-files-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
grid-auto-rows: minmax(48px, auto);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.portal-file-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
background: rgba(0,0,0,0.01);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.portal-file-card:hover {
|
||||
background: rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.portal-file-card-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--portal-accent, #0b5ed7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-file-card-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.portal-file-card-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.portal-file-card-meta {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.portal-file-card-actions {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
.portal-file-card-download {
|
||||
font-size: 0.78rem;
|
||||
text-decoration: none;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0,0,0,0.16);
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.portal-file-card-download:hover {
|
||||
background: var(--portal-accent, #0b5ed7);
|
||||
color: #fff;
|
||||
border-color: var(--portal-accent, #0b5ed7);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.portal-status {
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
#customToast {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
z-index: 4000;
|
||||
display: none;
|
||||
}
|
||||
#customToast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* (Optional) keep old row style around if anything else uses it */
|
||||
.portal-file-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.portal-file-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Client Portal – FileRise</title>
|
||||
<meta name="theme-color" content="#0b5ed7">
|
||||
|
||||
<style id="pretheme-css">
|
||||
html, body, #portalRoot { background: var(--pre-bg,#ffffff) !important; }
|
||||
</style>
|
||||
|
||||
<!-- Favicons / assets -->
|
||||
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
|
||||
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
|
||||
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||
|
||||
<meta name="csrf-token" content="">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
|
||||
<!-- CSS (reuse main app CSS for look) -->
|
||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||
|
||||
<!-- Version stamp -->
|
||||
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
|
||||
|
||||
<!-- Portal entry -->
|
||||
<script type="module" src="/js/portal.js?v={{APP_QVER}}"></script>
|
||||
|
||||
<style>
|
||||
.portal-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.portal-card {
|
||||
max-width: min(960px, 100%);
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||
padding: 20px 20px 16px;
|
||||
}
|
||||
.portal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.portal-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.portal-logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.portal-dropzone {
|
||||
border: 2px dashed rgba(0,0,0,0.2);
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.portal-dropzone.dragover {
|
||||
border-color: #0b5ed7;
|
||||
background: rgba(11,94,215,0.06);
|
||||
}
|
||||
.portal-files-list {
|
||||
margin-top: 14px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.portal-file-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.portal-file-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.portal-status {
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
#customToast {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
z-index: 4000;
|
||||
display: none;
|
||||
}
|
||||
#customToast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="portalRoot" class="portal-wrapper">
|
||||
<div class="portal-card">
|
||||
<div class="portal-header">
|
||||
<div class="portal-logo">
|
||||
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise">
|
||||
<div>
|
||||
<div id="portalBrandHeading" style="font-weight:600; font-size:1rem;">Client Portal</div>
|
||||
<div id="portalSubtitle" class="text-muted" style="font-size:0.8rem;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<small id="portalUserLabel" class="text-muted"></small>
|
||||
</div>
|
||||
<h3 id="portalTitle" style="margin-bottom:4px;">Loading…</h3>
|
||||
<p id="portalDescription" class="text-muted" style="margin-bottom:10px;"></p>
|
||||
|
||||
<div id="portalFormSection" style="margin-bottom:12px; display:none;">
|
||||
<h5 style="font-size:0.95rem; margin-bottom:4px;">Your details</h5>
|
||||
<p class="text-muted" style="font-size:0.8rem; margin-bottom:8px;">
|
||||
Please fill in your information before uploading files.
|
||||
</p>
|
||||
|
||||
<div class="form-group" style="margin-bottom:6px;">
|
||||
<label for="portalFormName">Name</label>
|
||||
<input type="text" id="portalFormName" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:6px;">
|
||||
<label for="portalFormEmail">Email</label>
|
||||
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:6px;">
|
||||
<label for="portalFormReference">Reference / Case / Order #</label>
|
||||
<input type="text" id="portalFormReference" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:8px;">
|
||||
<label for="portalFormNotes">Notes</label>
|
||||
<textarea id="portalFormNotes" class="form-control form-control-sm" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="button" id="portalFormSubmit" class="btn btn-primary btn-sm">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="portalUploadSection">
|
||||
<div id="portalDropzone" class="portal-dropzone">
|
||||
<div><strong>Drop files here</strong> or click to browse.</div>
|
||||
<div style="font-size:0.8rem;" class="text-muted">
|
||||
Files will be uploaded to this portal only.
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="portalFileInput" multiple style="display:none;">
|
||||
<div id="portalStatus" class="portal-status text-muted"></div>
|
||||
</div>
|
||||
|
||||
<div id="portalFilesSection" style="margin-top:12px; display:none;">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong style="font-size:0.95rem;">Files in this portal</strong>
|
||||
<button type="button" id="portalRefreshBtn" class="btn btn-sm btn-outline-secondary">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="portalFilesList" class="portal-files-list"></div>
|
||||
</div>
|
||||
<div id="portalFooter" class="text-muted"
|
||||
style="margin-top:12px; font-size:0.75rem; text-align:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="customToast"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 535 KiB |
BIN
resources/dark-client-portal1.png
Normal file
|
After Width: | Height: | Size: 488 KiB |
BIN
resources/dark-client-portal2.png
Normal file
|
After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 470 KiB After Width: | Height: | Size: 871 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 645 KiB After Width: | Height: | Size: 581 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 698 KiB |
BIN
resources/dark-user-groups.png
Normal file
|
After Width: | Height: | Size: 501 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 656 KiB |
BIN
resources/filerise-v2.0.0.png
Normal file
|
After Width: | Height: | Size: 737 KiB |
BIN
resources/portal-login.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
resources/portal-optional-form.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
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'] ?? ''));
|
||||
@@ -176,6 +176,7 @@ class AdminController
|
||||
'version' => $proVersion,
|
||||
'license' => $licenseString,
|
||||
],
|
||||
'demoMode' => defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false,
|
||||
];
|
||||
|
||||
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
|
||||
@@ -272,6 +273,192 @@ public function setLicense(): void
|
||||
}
|
||||
}
|
||||
|
||||
public function getProPortals(): 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.');
|
||||
}
|
||||
|
||||
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
|
||||
if (!is_file($proPortalsPath)) {
|
||||
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
|
||||
}
|
||||
|
||||
require_once $proPortalsPath;
|
||||
|
||||
// ProPortals is implemented in the Pro bundle and handles JSON storage.
|
||||
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
||||
$portals = $store->listPortals();
|
||||
|
||||
return $portals;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $portalsPayload Raw "portals" array from JSON body
|
||||
*/
|
||||
public function saveProPortals(array $portalsPayload): 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.');
|
||||
}
|
||||
|
||||
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
|
||||
if (!is_file($proPortalsPath)) {
|
||||
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
|
||||
}
|
||||
|
||||
require_once $proPortalsPath;
|
||||
|
||||
if (!is_array($portalsPayload)) {
|
||||
throw new InvalidArgumentException('Invalid portals format.');
|
||||
}
|
||||
|
||||
// Minimal normalization; deeper validation can live inside ProPortals
|
||||
$data = ['portals' => []];
|
||||
|
||||
foreach ($portalsPayload as $slug => $info) {
|
||||
$slug = trim((string)$slug);
|
||||
if ($slug === '') {
|
||||
continue;
|
||||
}
|
||||
if (!is_array($info)) {
|
||||
$info = [];
|
||||
}
|
||||
|
||||
$label = trim((string)($info['label'] ?? $slug));
|
||||
$folder = trim((string)($info['folder'] ?? ''));
|
||||
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
|
||||
$uploadOnly = !empty($info['uploadOnly']);
|
||||
$allowDownload = array_key_exists('allowDownload', $info)
|
||||
? !empty($info['allowDownload'])
|
||||
: true;
|
||||
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
|
||||
|
||||
// Optional branding + form behavior
|
||||
$title = trim((string)($info['title'] ?? ''));
|
||||
$introText = trim((string)($info['introText'] ?? ''));
|
||||
$requireForm = !empty($info['requireForm']);
|
||||
$brandColor = trim((string)($info['brandColor'] ?? ''));
|
||||
$footerText = trim((string)($info['footerText'] ?? ''));
|
||||
|
||||
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
|
||||
? $info['formDefaults']
|
||||
: [];
|
||||
|
||||
// Normalize defaults for known keys
|
||||
$formDefaults = [
|
||||
'name' => trim((string)($formDefaults['name'] ?? '')),
|
||||
'email' => trim((string)($formDefaults['email'] ?? '')),
|
||||
'reference' => trim((string)($formDefaults['reference'] ?? '')),
|
||||
'notes' => trim((string)($formDefaults['notes'] ?? '')),
|
||||
];
|
||||
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
|
||||
? $info['formRequired']
|
||||
: [];
|
||||
|
||||
$formRequired = [
|
||||
'name' => !empty($formRequired['name']),
|
||||
'email' => !empty($formRequired['email']),
|
||||
'reference' => !empty($formRequired['reference']),
|
||||
'notes' => !empty($formRequired['notes']),
|
||||
];
|
||||
|
||||
if ($folder === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data['portals'][$slug] = [
|
||||
'label' => $label,
|
||||
'folder' => $folder,
|
||||
'clientEmail' => $clientEmail,
|
||||
'uploadOnly' => $uploadOnly,
|
||||
'allowDownload' => $allowDownload,
|
||||
'expiresAt' => $expiresAt,
|
||||
// NEW
|
||||
'title' => $title,
|
||||
'introText' => $introText,
|
||||
'requireForm' => $requireForm,
|
||||
'brandColor' => $brandColor,
|
||||
'footerText' => $footerText,
|
||||
'formDefaults' => $formDefaults,
|
||||
'formRequired' => $formRequired,
|
||||
];
|
||||
}
|
||||
|
||||
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
||||
$ok = $store->savePortals($data);
|
||||
|
||||
if (!$ok) {
|
||||
throw new RuntimeException('Could not write portals.json');
|
||||
}
|
||||
}
|
||||
|
||||
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 +561,6 @@ public function installProBundle(): void
|
||||
|
||||
$installed = [
|
||||
'src' => [],
|
||||
'public' => [],
|
||||
'docs' => [],
|
||||
];
|
||||
|
||||
@@ -436,21 +622,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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
123
src/controllers/PortalController.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
// src/controllers/PortalController.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
final class PortalController
|
||||
{
|
||||
/**
|
||||
* Look up a portal by slug from the Pro bundle.
|
||||
*
|
||||
* Returns:
|
||||
* [
|
||||
* 'slug' => string,
|
||||
* 'label' => string,
|
||||
* 'folder' => string,
|
||||
* 'clientEmail' => string,
|
||||
* 'uploadOnly' => bool,
|
||||
* 'allowDownload' => bool,
|
||||
* 'expiresAt' => string,
|
||||
* 'title' => string,
|
||||
* 'introText' => string,
|
||||
* 'requireForm' => bool
|
||||
* ]
|
||||
*/
|
||||
public static function getPortalBySlug(string $slug): array
|
||||
{
|
||||
$slug = trim($slug);
|
||||
if ($slug === '') {
|
||||
throw new InvalidArgumentException('Missing portal slug.');
|
||||
}
|
||||
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||
throw new RuntimeException('FileRise Pro is not active.');
|
||||
}
|
||||
if (!defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
||||
throw new RuntimeException('Pro bundle directory not configured.');
|
||||
}
|
||||
|
||||
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
|
||||
if (!is_file($proPortalsPath)) {
|
||||
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
|
||||
}
|
||||
|
||||
require_once $proPortalsPath;
|
||||
|
||||
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
||||
$portals = $store->listPortals();
|
||||
|
||||
if (!isset($portals[$slug]) || !is_array($portals[$slug])) {
|
||||
throw new RuntimeException('Portal not found.');
|
||||
}
|
||||
|
||||
$p = $portals[$slug];
|
||||
|
||||
$label = trim((string)($p['label'] ?? $slug));
|
||||
$folder = trim((string)($p['folder'] ?? ''));
|
||||
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
||||
$uploadOnly = !empty($p['uploadOnly']);
|
||||
$allowDownload = array_key_exists('allowDownload', $p)
|
||||
? !empty($p['allowDownload'])
|
||||
: true;
|
||||
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
||||
|
||||
// NEW: optional branding + intake behavior
|
||||
$title = trim((string)($p['title'] ?? ''));
|
||||
$introText = trim((string)($p['introText'] ?? ''));
|
||||
$requireForm = !empty($p['requireForm']);
|
||||
$brandColor = trim((string)($p['brandColor'] ?? ''));
|
||||
$footerText = trim((string)($p['footerText'] ?? ''));
|
||||
|
||||
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
|
||||
? $p['formDefaults']
|
||||
: [];
|
||||
|
||||
$formDefaults = [
|
||||
'name' => trim((string)($fd['name'] ?? '')),
|
||||
'email' => trim((string)($fd['email'] ?? '')),
|
||||
'reference' => trim((string)($fd['reference'] ?? '')),
|
||||
'notes' => trim((string)($fd['notes'] ?? '')),
|
||||
];
|
||||
$fr = isset($p['formRequired']) && is_array($p['formRequired'])
|
||||
? $p['formRequired']
|
||||
: [];
|
||||
|
||||
$formRequired = [
|
||||
'name' => !empty($fr['name']),
|
||||
'email' => !empty($fr['email']),
|
||||
'reference' => !empty($fr['reference']),
|
||||
'notes' => !empty($fr['notes']),
|
||||
];
|
||||
|
||||
if ($folder === '') {
|
||||
throw new RuntimeException('Portal misconfigured: empty folder.');
|
||||
}
|
||||
|
||||
// Expiry check
|
||||
if ($expiresAt !== '') {
|
||||
$ts = strtotime($expiresAt . ' 23:59:59');
|
||||
if ($ts !== false && $ts < time()) {
|
||||
throw new RuntimeException('This portal has expired.');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'slug' => $slug,
|
||||
'label' => $label,
|
||||
'folder' => $folder,
|
||||
'clientEmail' => $clientEmail,
|
||||
'uploadOnly' => $uploadOnly,
|
||||
'allowDownload' => $allowDownload,
|
||||
'expiresAt' => $expiresAt,
|
||||
|
||||
'title' => $title,
|
||||
'introText' => $introText,
|
||||
'requireForm' => $requireForm,
|
||||
'brandColor' => $brandColor,
|
||||
'footerText' => $footerText,
|
||||
'formDefaults' => $formDefaults,
|
||||
'formRequired' => $formRequired,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,15 @@ class UserController
|
||||
echo json_encode(["error" => "No username in session"]);
|
||||
exit;
|
||||
}
|
||||
// Block changing the demo account password when in demo mode
|
||||
if (FR_DEMO_MODE && $username === 'demo') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Password changes are disabled on the public demo.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = self::readJson();
|
||||
$oldPassword = trim($data["oldPassword"] ?? "");
|
||||
@@ -608,6 +617,15 @@ class UserController
|
||||
self::requireAuth();
|
||||
self::requireCsrf();
|
||||
|
||||
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Profile picture changes are disabled in the demo environment.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,7 @@ private static function sanitizeLogoUrl($url): string
|
||||
$config['branding']['headerBgDark'] ?? ''
|
||||
),
|
||||
],
|
||||
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
|
||||
];
|
||||
|
||||
// NEW: include ONLYOFFICE minimal public flag
|
||||
@@ -136,16 +137,17 @@ private static function sanitizeLogoUrl($url): string
|
||||
$locked = defined('ONLYOFFICE_ENABLED') || defined('ONLYOFFICE_JWT_SECRET')
|
||||
|| defined('ONLYOFFICE_DOCS_ORIGIN') || defined('ONLYOFFICE_PUBLIC_ORIGIN');
|
||||
|
||||
if ($locked) {
|
||||
$ooEnabled = defined('ONLYOFFICE_ENABLED') ? (bool)ONLYOFFICE_ENABLED : false;
|
||||
} else {
|
||||
$ooEnabled = isset($config['onlyoffice']['enabled']) ? (bool)$config['onlyoffice']['enabled'] : false;
|
||||
}
|
||||
if ($locked) {
|
||||
$ooEnabled = defined('ONLYOFFICE_ENABLED') ? (bool)ONLYOFFICE_ENABLED : false;
|
||||
} else {
|
||||
$ooEnabled = isset($config['onlyoffice']['enabled']) ? (bool)$config['onlyoffice']['enabled'] : false;
|
||||
}
|
||||
|
||||
$public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||
$public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||
$public['demoMode'] = defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false;
|
||||
|
||||
return $public;
|
||||
}
|
||||
return $public;
|
||||
}
|
||||
|
||||
/** Write USERS_DIR/siteConfig.json atomically (unencrypted). */
|
||||
public static function writeSiteConfig(array $publicSubset): array
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||