Compare commits

...

24 Commits

Author SHA1 Message Date
github-actions[bot]
997e5067d3 chore(release): set APP_VERSION to v2.2.1 [skip ci] 2025-11-29 00:24:52 +00:00
Ryan
1c0ac50048 release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage 2025-11-28 19:24:42 -05:00
github-actions[bot]
8fc716387b chore(release): set APP_VERSION to v2.2.0 [skip ci] 2025-11-29 00:04:09 +00:00
Ryan
fe3a58924b release(v2.2.0): add storage explorer + disk usage scanner 2025-11-28 19:04:00 -05:00
github-actions[bot]
47b4cc4489 chore(release): set APP_VERSION to v2.1.0 [skip ci] 2025-11-27 07:04:40 +00:00
Ryan
3f0d1780a1 release(v2.1.0): add header zoom controls, preview tags & modal/dock polish 2025-11-27 02:04:29 -05:00
github-actions[bot]
3b62e27c7c chore(release): set APP_VERSION to v2.0.4 [skip ci] 2025-11-27 02:42:10 +00:00
Ryan
f967134631 release(v2.0.4): harden sessions and align Pro paths with USERS_DIR 2025-11-26 21:41:59 -05:00
Ryan
6b93d65d6a docs(readme): add Heise / iX press section 2025-11-26 18:36:05 -05:00
github-actions[bot]
1856325b1f chore(release): set APP_VERSION to v2.0.3 [skip ci] 2025-11-26 08:58:36 +00:00
Ryan
9e6da52691 release(v2.0.3): polish uploads, header dock, and panel fly animations 2025-11-26 03:58:25 -05:00
Ryan
959206c91c docs(readme): link install, nginx and FAQ wiki pages 2025-11-23 22:11:28 -05:00
Ryan
837deddec5 docs: add full feature wiki to README 2025-11-23 22:07:06 -05:00
Ryan
2810b97568 chore(demo): update manual sync script and lock TOTP for demo account
- Update scripts/manual-sync.sh to pull v2.0.2, backup extra demo/Pro dirs,
  and safely rsync core code without touching data, bundles, or site overrides
- After sync, automatically flip FR_DEMO_MODE to true in config/config.php
  so the droplet always runs in demo mode
- Block TOTP enable/disable/setup and recovery code generation for the
  demo account when FR_DEMO_MODE is enabled, returning 403 with clear
  JSON errors
2025-11-23 06:43:51 -05:00
github-actions[bot]
175c5f962f chore(release): set APP_VERSION to v2.0.2 [skip ci] 2025-11-23 10:58:51 +00:00
Ryan
827e65e367 release(v2.0.2): add config-driven demo mode and lock demo account changes 2025-11-23 05:58:39 -05:00
Ryan
fd8029a6bf docs: highlight Pro user groups and client portals in README 2025-11-23 04:54:35 -05:00
github-actions[bot]
de79395c3d chore(release): set APP_VERSION to v2.0.1 [skip ci] 2025-11-23 09:29:51 +00:00
Ryan
aa6f40bc24 release(v2.0.1): fix: harden portal + core login redirects for codeql 2025-11-23 04:29:41 -05:00
Ryan
abc105e087 chore(docs): readme image updated 2025-11-23 04:19:09 -05:00
github-actions[bot]
d3bcac4db0 chore(release): set APP_VERSION to v2.0.0 [skip ci] 2025-11-23 09:15:59 +00:00
Ryan
0b065111b0 release(v2.0.0): feat(pro): client portals + portal login flow 2025-11-23 04:15:49 -05:00
github-actions[bot]
3589a1c232 chore(release): set APP_VERSION to v1.9.14 [skip ci] 2025-11-21 07:12:29 +00:00
Ryan
1b4a93b060 release(v1.9.14): inline folder rows, synced folder icons, and compact theme polish 2025-11-21 02:12:17 -05:00
64 changed files with 9224 additions and 1497 deletions

View File

@@ -1,5 +1,217 @@
# Changelog
## Changes 11/28/2025 (v2.2.1)
release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage
- Refactor adminStorage breadcrumb builder to construct DOM nodes instead of using innerHTML.
- Rework Storage explorer folder view to render rows via createElement/textContent, avoiding DOM text reinterpreted as HTML.
- Keep deep-delete and pagination behavior unchanged while tightening up XSS/CodeQL concerns.
- Update README feature list to mention disk usage summary and Pro storage explorer (ncdu-style) alongside user groups and client portals.
## Changes 11/28/2025 (v2.2.0)
release(v2.2.0): add storage explorer + disk usage scanner
- New **Storage / Disk Usage** admin section with snapshot-based totals and "Top folders by size".
- Disk usage CLI scanner (`src/cli/disk_usage_scan.php`) and background rescan endpoint.
- New **Storage Explorer** (drilldown, top files view, deep-delete actions) available in FileRise Pro v1.2.0.
- Non-Pro installsshow a blurred preview of the explorer with upgrade prompts.
Features
- Add new "Storage / Disk Usage" section to the Admin Panel with a summary card and "Top folders by size" table.
- Introduce CLI disk usage scanner (src/cli/disk_usage_scan.php) that walks UPLOAD_DIR, applies FS::IGNORE()/SKIP(), and persists a structured snapshot to META_DIR/disk_usage.json.
- Add /api/admin/diskUsageSummary.php and /api/admin/diskUsageTriggerScan.php endpoints to expose the snapshot and trigger background rescans from the UI.
- Wire the new storage section into adminPanel.js with a Rescan button that launches the CLI worker and polls for a fresh snapshot.
Improvements
- Storage summary now shows total files, folders, scan duration, and last scan time, plus grouped volume usage across Uploads / Users / Metadata when available.
- "Top folders by size" table supports a Pro-only "show more" interaction, but still provides a clean preview in the core edition.
- Slight spacing / layout tweaks so the Storage card doesnt sit flush against the Admin Panel header.
Pro integration
- Keep the full ncdu-style "Storage explorer" (per-folder drilldown + global Top files, deep delete toggle, size filters, etc.) behind FR_PRO_ACTIVE via /api/pro/diskUsageChildren.php and /api/pro/diskUsageTopFiles.php.
- Pro-only delete-from-explorer actions are exposed via /api/pro/diskUsageDeleteFilePermanent.php and /api/pro/diskUsageDeleteFolderRecursive.php, reusing FileModel and FolderModel admin helpers.
- Non-Pro instances still see the explorer teaser, but the table body is blurred and padded with "Pro" badges, clearly advertising the upgrade path without exposing the Pro internals.
DX / internals
- Centralize disk usage logic in DiskUsageModel: snapshot builder, summary (including volumes), per-folder children view, and global Top N file listing.
- Ensure adminStorage.js is idempotent and safe to re-init when the Admin Panel is reopened (guards on data-* flags, re-wires only once).
- Add robust PHP-CLI discovery and log output for the disk usage worker, mirroring the existing zip worker pattern.
---
## Changes 11/27/2025 (v2.1.0)
🦃🍂 Happy Thanksgiving. 🥧🍁🍽️
release(v2.1.0): add header zoom controls, preview tags & modal/dock polish
- **feat(ux): header zoom controls with persisted app zoom**
- Add `zoom.js` with percent-based zoom API (`window.fileriseZoom`) and `--app-zoom` CSS variable.
- Wrap the main app in `#appZoomShell` and scale via `transform: scale(var(--app-zoom))` so the whole UI zooms uniformly.
- Add header zoom UI (+ / / 100% reset) and wire it via `data-zoom` buttons.
- Persist zoom level in `localStorage` and restore on load.
- **feat(prefs): user toggle to hide header zoom controls**
- Add `hide_header_zoom_controls` i18n key.
- Extend the Settings → Display fieldset with “Hide header zoom controls”.
- Store preference in `localStorage('hideZoomControls')` and respect it from `appCore.js` when initializing header zoom UI.
- **feat(preview): show file tags next to preview title**
- Add `.title-tags` container in the media viewer header.
- When opening a file, look up its `tags` from `fileData` and render them as pill badges beside the filename in the modal top bar.
- **fix(modals): folder modals always centered above header cards**
- Introduce `detachFolderModalsToBody()` in `folderManager.js` and call it on init + before opening create/rename/move/delete modals.
- Move those modals under `document.body` with a stable high `z-index`, so theyre not clipped/hidden when the cards live in the header dock.
- **fix(dnd): header dock & hidden cards container**
- Change `#hiddenCardsContainer` from `display:none` to an off-screen absolutely positioned container so card internals (modals/layout) still work while represented as header icons.
- Ensure sidebar is always visible as a drop target while dragging (even when panels are collapsed), plus improved highlight & placeholder behavior.
- **feat(ux): header dock hover/lock polish**
- Make header icon buttons share the same hover style as other header buttons.
- Add `.is-locked` state so a pinned header icon stays visually “pressed” while its card modal is locked open.
- **feat(ux): header drop zone and zoom bar layout**
- Rework `.header-right` to neatly align zoom controls, header dock, and user buttons.
- Add a more flexible `.header-drop-zone` with smooth width/padding transitions and a centered `"Drop Zone"` label when active and empty.
- Adjust responsive spacing around zoom controls on smaller screens.
- **tweak(prefs-modal): improve settings modal sizing**
- Increase auth/settings modal `max-height` from 500px to 600px to fit the extra display options without excessive scrolling.
---
## Changes 11/26/2025 (v2.0.4)
release(v2.0.4): harden sessions and align Pro paths with USERS_DIR
- Enable strict_types in config.php and AdminController
- Decouple PHP session lifetime from "remember me" window
- Regenerate session ID on persistent token auto-login
- Point Pro license / bundle paths at USERS_DIR instead of hardcoded /users
- Tweak folder management card drag offset for better alignment
---
## Changes 11/26/2025 (v2.0.3)
release(v2.0.3): polish uploads, header dock, and panel fly animations
- Rework upload drop area markup to be rebuild-safe and wire a guarded "Choose files" button
so only one OS file-picker dialog can open at a time.
- Centralize file input change handling and reset selectedFiles/_currentResumableIds per batch
to avoid duplicate resumable entries and keep the progress list/drafts in sync.
- Ensure drag-and-drop uploads still support folder drops while file-picker is files-only.
- Add ghost-based animations when collapsing panels into the header dock and expanding them back
to sidebar/top zones, inheriting card background/border/shadow for smooth visuals.
- Offset sidebar ghosts so upload and folder cards don't stack directly on top of each other.
- Respect header-pinned cards: cards saved to HEADER stay as icons and no longer fly out on expand.
- Slightly tighten file summary margin in the file list header for better alignment with actions.
---
## 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 youre 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 portals 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

View File

@@ -10,22 +10,26 @@
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-❤-red)](https://github.com/sponsors/error311)
[![Support on Ko-fi](https://img.shields.io/badge/Ko--fi-Buy%20me%20a%20coffee-orange)](https://ko-fi.com/error311)
**FileRise** is a modern, selfhosted web file manager / WebDAV server.
Drag & drop uploads, ACLaware 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.
- 💾 **Selfhosted “cloud drive”** Runs anywhere with PHP (or via Docker). No external DB required.
- 🔐 **Granular perfolder ACLs** View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
- 🔄 **Fast draganddrop 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, inbrowser previews & code editor.
- 📊 **Storage / disk usage summary** CLI scanner with snapshots, total usage, and per-volume breakdowns in the admin panel.
- 🎨 **Polished UI** Dark/light mode, responsive layout, in-browser previews & code editor.
- 🔑 **Login + SSO** Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
- 👥 **Pro: user groups, client portals & storage explorer** Group-based ACLs, brandable client upload portals, and an ncdu-style explorer to drill into folders, largest files, and clean up storage inline.
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v1.9.7.png)
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
> 💡 Looking for **FileRise Pro** (brandable header, Pro features, license handling)?
> Check out [filerise.net](https://filerise.net) FileRise Core stays fully opensource (MIT).
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v2.0.0.png)
> 💡 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).
---
@@ -73,7 +77,10 @@ http://your-server-ip:8080
On first launch youll be guided through creating the **initial admin user**.
**More Docker options (Unraid, dockercompose, env vars, reverse proxy, etc.)**
**More Docker options (Unraid, dockercompose, env vars, reverse proxy, etc.)**
[Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup)
[nginx](https://github.com/error311/FileRise/wiki/Nginx-Setup)
[FAQ](https://github.com/error311/FileRise/wiki/FAQ)
See the Docker repo: [docker repo](https://github.com/error311/filerise-docker)
---
@@ -188,3 +195,8 @@ It bundles a small set of wellknown client and server libraries (Bootstrap, C
All thirdparty code remains under its original licenses.
See `THIRD_PARTY.md` and the `licenses/` folder for full details.
## 8. Press
- [Heise / iX Magazin “FileRise 2.0: Web-Dateimanager mit Client Portals” (DE)](https://www.heise.de/news/FileRise-2-0-Web-Dateimanager-mit-Client-Portals-11092171.html)
- [Heise / iX Magazin “FileRise 2.0: Web File Manager with Client Portals” (EN)](https://www.heise.de/en/news/FileRise-2-0-Web-File-Manager-with-Client-Portals-11092376.html)

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
// config.php
// Define constants
@@ -16,6 +17,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);
@@ -100,10 +102,15 @@ $secure = ($envSecure !== false)
? filter_var($envSecure, FILTER_VALIDATE_BOOLEAN)
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
// Choose session lifetime based on "remember me" cookie
// PHP session lifetime (independent of "remember me")
// Keep this reasonably short; "remember me" uses its own token.
$defaultSession = 7200; // 2 hours
$sessionLifetime = $defaultSession;
// "Remember me" window (how long the persistent token itself is valid)
// This is used in persistent_tokens.json, *not* for PHP session lifetime.
$persistentDays = 30 * 24 * 60 * 60; // 30 days
$sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession;
/**
* Start session idempotently:
@@ -154,6 +161,11 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
if (!empty($tokens[$token])) {
$data = $tokens[$token];
if ($data['expiry'] >= time()) {
// NEW: mitigate session fixation
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $data["username"];
$_SESSION["folderOnly"] = loadUserPermissions($data["username"]);
@@ -161,7 +173,11 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
} else {
// expired — clean up
unset($tokens[$token]);
file_put_contents($tokFile, encryptData(json_encode($tokens, JSON_PRETTY_PRINT), $encryptionKey), LOCK_EX);
file_put_contents(
$tokFile,
encryptData(json_encode($tokens, JSON_PRETTY_PRINT), $encryptionKey),
LOCK_EX
);
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
}
}
@@ -252,14 +268,14 @@ if (!defined('FR_PRO_LICENSE')) {
// JSON license file used by AdminController::setLicense()
if (!defined('PRO_LICENSE_FILE')) {
define('PRO_LICENSE_FILE', PROJECT_ROOT . '/users/proLicense.json');
define('PRO_LICENSE_FILE', rtrim(USERS_DIR, "/\\") . '/proLicense.json');
}
// Optional plain-text license file (used as fallback in bootstrap)
if (!defined('FR_PRO_LICENSE_FILE')) {
$lf = getenv('FR_PRO_LICENSE_FILE');
if ($lf === false || $lf === '') {
$lf = PROJECT_ROOT . '/users/proLicense.txt';
$lf = rtrim(USERS_DIR, "/\\") . '/proLicense.txt';
}
define('FR_PRO_LICENSE_FILE', $lf);
}
@@ -267,7 +283,7 @@ if (!defined('FR_PRO_LICENSE_FILE')) {
// Where Pro code lives by default → inside users volume
$proDir = getenv('FR_PRO_BUNDLE_DIR');
if ($proDir === false || $proDir === '') {
$proDir = PROJECT_ROOT . '/users/pro';
$proDir = rtrim(USERS_DIR, "/\\") . '/pro';
}
$proDir = rtrim($proDir, "/\\");
if (!defined('FR_PRO_BUNDLE_DIR')) {

View File

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

View File

@@ -0,0 +1,41 @@
<?php
// public/api/admin/diskUsageSummary.php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/models/DiskUsageModel.php';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
header('Content-Type: application/json; charset=utf-8');
$authenticated = !empty($_SESSION['authenticated']);
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
if (!$authenticated || !$isAdmin) {
http_response_code(401);
echo json_encode([
'ok' => false,
'error' => 'Unauthorized',
]);
exit;
}
// Optional tuning via query params
$topFolders = isset($_GET['topFolders']) ? max(1, (int)$_GET['topFolders']) : 5;
$topFiles = isset($_GET['topFiles']) ? max(0, (int)$_GET['topFiles']) : 0;
try {
$summary = DiskUsageModel::getSummary($topFolders, $topFiles);
http_response_code($summary['ok'] ? 200 : 404);
echo json_encode($summary, JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'internal_error',
'message' => $e->getMessage(),
]);
}

View File

@@ -0,0 +1,102 @@
<?php
// public/api/admin/diskUsageTriggerScan.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/models/DiskUsageModel.php';
// Basic auth / admin check
if (session_status() !== PHP_SESSION_ACTIVE) {
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([
'ok' => false,
'error' => 'Forbidden',
]);
return;
}
// Release session lock early so the scanner/other requests aren't blocked
@session_write_close();
// NOTE: previously this endpoint was Pro-only. Now it works on all instances.
// Pro-only gate removed so free FileRise can also use the Rescan button.
/*
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'FileRise Pro is not active on this instance.',
]);
return;
}
*/
try {
$worker = realpath(PROJECT_ROOT . '/src/cli/disk_usage_scan.php');
if (!$worker || !is_file($worker)) {
throw new RuntimeException('disk_usage_scan.php not found.');
}
// Find a PHP CLI binary that actually works (same idea as zip_worker)
$candidates = array_values(array_filter([
PHP_BINARY ?: null,
'/usr/local/bin/php',
'/usr/bin/php',
'/bin/php',
]));
$php = null;
foreach ($candidates as $bin) {
if (!$bin) {
continue;
}
$rc = 1;
@exec(escapeshellcmd($bin) . ' -v >/dev/null 2>&1', $out, $rc);
if ($rc === 0) {
$php = $bin;
break;
}
}
if (!$php) {
throw new RuntimeException('No working php CLI found.');
}
$meta = rtrim((string)META_DIR, '/\\');
$logDir = $meta . DIRECTORY_SEPARATOR . 'logs';
@mkdir($logDir, 0775, true);
$logFile = $logDir . DIRECTORY_SEPARATOR . 'disk_usage_scan.log';
// nohup php disk_usage_scan.php >> log 2>&1 & echo $!
$cmdStr =
'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) .
' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
$pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
$pid = is_string($pid) ? (int)trim($pid) : 0;
http_response_code(200);
echo json_encode([
'ok' => true,
'pid' => $pid > 0 ? $pid : null,
'message' => 'Disk usage scan started in the background.',
'logFile' => $logFile,
], JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'internal_error',
'message' => $e->getMessage(),
]);
}

View File

@@ -0,0 +1,53 @@
<?php
// public/api/pro/diskUsageChildren.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
// Basic auth / admin check
if (session_status() !== PHP_SESSION_ACTIVE) {
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([
'ok' => false,
'error' => 'Forbidden',
]);
return;
}
// Release session lock to avoid blocking parallel requests
@session_write_close();
// Pro-only gate: require Pro active AND ProDiskUsage class available
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProDiskUsage')) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'FileRise Pro is not active on this instance.',
]);
return;
}
$folderKey = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
try {
/** @var array $result */
$result = ProDiskUsage::getChildren($folderKey);
http_response_code(!empty($result['ok']) ? 200 : 404);
echo json_encode($result, JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'internal_error',
'message' => $e->getMessage(),
]);
}

View File

@@ -0,0 +1,55 @@
<?php
// public/api/pro/diskUsageDeleteFilePermanent.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';
require_once PROJECT_ROOT . '/src/models/FileModel.php';
// Pro-only gate: make sure Pro is really active
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'FileRise Pro is not active on this instance.']);
return;
}
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => 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) || empty($body['name'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid input']);
return;
}
$folder = isset($body['folder']) ? (string)$body['folder'] : 'root';
$folder = $folder === '' ? 'root' : trim($folder, "/\\ ");
$name = (string)$body['name'];
$res = FileModel::deleteFilesPermanent($folder, [$name]);
if (!empty($res['error'])) {
echo json_encode(['ok' => false, 'error' => $res['error']]);
} else {
echo json_encode(['ok' => true, 'success' => $res['success'] ?? 'File deleted.']);
}
} catch (Throwable $e) {
error_log('diskUsageDeleteFilePermanent error: '.$e->getMessage());
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Internal error']);
}

View File

@@ -0,0 +1,60 @@
<?php
// public/api/pro/diskUsageDeleteFolderRecursive.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';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
// Pro-only gate
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'FileRise Pro is not active on this instance.']);
return;
}
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => 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) || !isset($body['folder'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid input']);
return;
}
$folder = (string)$body['folder'];
$folder = $folder === '' ? 'root' : trim($folder, "/\\ ");
if (strtolower($folder) === 'root') {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Cannot deep delete root folder.']);
return;
}
$res = FolderModel::deleteFolderRecursiveAdmin($folder);
if (!empty($res['error'])) {
echo json_encode(['ok' => false, 'error' => $res['error']]);
} else {
echo json_encode(['ok' => true, 'success' => $res['success'] ?? 'Folder deleted.']);
}
} catch (Throwable $e) {
error_log('diskUsageDeleteFolderRecursive error: '.$e->getMessage());
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Internal error']);
}

View File

@@ -0,0 +1,51 @@
<?php
// public/api/pro/diskUsageTopFiles.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
// Basic auth / admin check
if (session_status() !== PHP_SESSION_ACTIVE) {
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([
'ok' => false,
'error' => 'Forbidden',
]);
return;
}
@session_write_close();
// Pro-only gate: require Pro active AND ProDiskUsage class
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProDiskUsage')) {
http_response_code(403);
echo json_encode([
'ok' => false,
'error' => 'FileRise Pro is not active on this instance.',
]);
return;
}
$limit = isset($_GET['limit']) ? max(1, (int)$_GET['limit']) : 100;
try {
$result = ProDiskUsage::getTopFiles($limit);
http_response_code(!empty($result['ok']) ? 200 : 404);
echo json_encode($result, JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => 'internal_error',
'message' => $e->getMessage(),
]);
}

View 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);
}

View 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);
}

View 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 youre 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,
]);

View 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);
}

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

View 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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
<!-- Fonts (ok to keep as real preloads) -->
<!-- Fonts -->
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
@@ -61,7 +61,27 @@
<h1>FileRise</h1>
</div>
<div class="header-right">
<div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;">
<!-- Zoom controls FIRST on the right -->
<div class="header-zoom-controls">
<!-- Left stack: + / - -->
<div class="zoom-vertical">
<button class="btn-icon zoom-btn" data-zoom="in" title="Zoom in">
<span class="material-icons">add</span>
</button>
<button class="btn-icon zoom-btn" data-zoom="out" title="Zoom out">
<span class="material-icons">remove</span>
</button>
</div>
<!-- Right stack: 100% / reset -->
<div class="zoom-meta">
<span id="zoomDisplay" class="zoom-display">100%</span>
<button class="btn-icon zoom-btn" data-zoom="reset" title="Reset zoom">
<span class="material-icons">refresh</span>
</button>
</div>
</div>
<div class="header-buttons-wrapper" style="display: flex; align-items: center;">
<div id="headerDropArea" class="header-drop-zone"></div>
<div class="header-buttons">
@@ -112,6 +132,7 @@
<!-- Custom Toast Container -->
<div id="customToast"></div>
<div id="hiddenCardsContainer" style="display:none;"></div>
<div id="appZoomShell">
<main id="main" hidden>
<div class="row mt-4" id="loginForm">
<div class="col-12">
@@ -190,10 +211,6 @@
<div id="folderManagementCard" class="card" style="width: 100%; position: relative;">
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
<span data-i18n-key="folder_navigation">Folder Navigation &amp; Management</span>
<button id="folderHelpBtn" class="btn btn-link" data-i18n-title="folder_help"
style="padding: 0; border: none; background: none;">
<i class="material-icons folder-help-icon" style="font-size: 24px;">info</i>
</button>
</div>
<div class="card-body custom-folder-card-body">
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
@@ -276,27 +293,6 @@
</div>
</div>
</div>
<div id="folderHelpTooltip" class="folder-help-tooltip"
style="display:none;position:absolute;top:50px;right:15px;background:#fff;border:1px solid #ccc;padding:10px;z-index:1000;box-shadow:2px 2px 6px rgba(0,0,0,0.2);border-radius:8px;max-width:320px;line-height:1.35;">
<style>
/* Dark mode polish */
body.dark-mode #folderHelpTooltip {
background:#2c2c2c; border-color:#555; color:#e8e8e8; box-shadow:2px 2px 10px rgba(0,0,0,.5);
}
#folderHelpTooltip .folder-help-list { margin:0; padding-left:18px; }
#folderHelpTooltip .folder-help-list li { margin:6px 0; }
</style>
<ul class="folder-help-list">
<li data-i18n-key="folder_help_click_view">Click a folder in the tree to view its files.</li>
<li data-i18n-key="folder_help_expand_chevrons">Use chevrons to expand/collapse. Locked folders (padlock) can expand but cant be opened.</li>
<li data-i18n-key="folder_help_context_menu">Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.</li>
<li data-i18n-key="folder_help_drag_drop">Drag a folder onto another folder <em>or</em> a breadcrumb to move it.</li>
<li data-i18n-key="folder_help_load_more">For long lists, click “Load more” to fetch the next page of folders.</li>
<li data-i18n-key="folder_help_last_folder">Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.</li>
<li data-i18n-key="folder_help_breadcrumbs">Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.</li>
<li data-i18n-key="folder_help_permissions">Buttons enable/disable based on your permissions for the selected folder.</li>
</ul>
</div>
</div>
</div>
</div>
@@ -401,7 +397,7 @@
</div> <!-- end container-fluid -->
</div> <!-- end mainColumn -->
</div> <!-- end main-wrapper -->
</div>
<!-- Download Progress Modal -->
<div id="downloadProgressModal" class="modal" style="display: none;">
<div class="modal-content" style="text-align: center; padding: 20px;">

File diff suppressed because it is too large Load Diff

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

1684
public/js/adminStorage.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -90,7 +90,26 @@ 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';
const zoomWrap = document.querySelector('.header-zoom-controls');
if (zoomWrap) {
const hideZoom = localStorage.getItem('hideZoomControls') === 'true';
if (hideZoom) {
zoomWrap.style.display = 'none';
zoomWrap.setAttribute('aria-hidden', 'true');
} else {
zoomWrap.style.display = 'flex';
zoomWrap.removeAttribute('aria-hidden');
}
// Always load zoom.js once app is running
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
import(`/js/zoom.js?v=${encodeURIComponent(QVER)}`).catch(err => {
console.warn('[zoom] failed to load zoom.js', err);
});
}
// Load public site config early (safe subset)
loadAdminConfigFunc();
@@ -99,6 +118,7 @@ export function initializeApp() {
initTagSearch();
/*
// Hook DnD relay from fileList area into upload area
const fileListArea = document.getElementById('fileList');
@@ -146,7 +166,7 @@ export function initializeApp() {
uploadArea.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
}
});
}
}*/
// App subsystems
initDragAndDrop();
@@ -174,6 +194,25 @@ export function initializeApp() {
}
}
// ---- Zoom controls: load only for logged-in app ----
(function loadZoomControls() {
const zoomWrap = document.querySelector('.header-zoom-controls');
if (!zoomWrap) return;
// show container (keep CSS default = hidden)
zoomWrap.style.display = 'flex';
zoomWrap.style.alignItems = 'center';
try {
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
import(`/js/zoom.js?v=${encodeURIComponent(QVER)}`)
.catch(err => console.warn('[zoom] failed to load:', err));
} catch (e) {
console.warn('[zoom] load error:', e);
}
})();
/* =========================
LOGOUT (shared)
========================= */

View File

@@ -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);
}
// Dont nag during pending TOTP, as you already had
// Dont 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() isnt 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();

View File

@@ -195,7 +195,7 @@ export async function openUserPanel() {
color: ${isDark ? '#e0e0e0' : '#000'};
padding: 20px;
max-width: 600px; width:90%;
overflow-y: auto; max-height: 500px;
overflow-y: auto; max-height: 600px;
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
box-sizing: border-box;
scrollbar-width: none;
@@ -351,30 +351,115 @@ export async function openUserPanel() {
langFs.appendChild(langSel);
content.appendChild(langFs);
// --- Display fieldset: “Show folders above files” ---
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);
content.appendChild(dispFs);
// --- 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);
// 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');
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);
inlineLabel.append(` ${t('show_inline_folders') || 'Show folders inline (above files)'}`);
dispFs.appendChild(inlineLabel);
// 3) Hide header zoom controls
const zoomLabel = document.createElement('label');
zoomLabel.style.cursor = 'pointer';
zoomLabel.style.display = 'block';
zoomLabel.style.marginTop = '4px';
const zoomCb = document.createElement('input');
zoomCb.type = 'checkbox';
zoomCb.id = 'hideHeaderZoomControls';
zoomCb.style.verticalAlign = 'middle';
{
const storedZoom = localStorage.getItem('hideZoomControls');
zoomCb.checked = storedZoom === 'true';
}
zoomLabel.appendChild(zoomCb);
zoomLabel.append(` ${t('hide_header_zoom_controls') || 'Hide zoom controls in header'}`);
dispFs.appendChild(zoomLabel);
content.appendChild(dispFs);
// 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');
}
});
// NEW: zoom hide/show handler
zoomCb.addEventListener('change', () => {
const hideZoom = zoomCb.checked;
localStorage.setItem('hideZoomControls', hideZoom ? 'true' : 'false');
const zoomWrap = document.querySelector('.header-zoom-controls');
if (!zoomWrap) return;
if (hideZoom) {
zoomWrap.style.display = 'none';
zoomWrap.setAttribute('aria-hidden', 'true');
} else {
zoomWrap.style.display = 'flex';
zoomWrap.removeAttribute('aria-hidden');
}
});
dispCb.addEventListener('change', () => {
window.showFoldersInList = dispCb.checked;
localStorage.setItem('showFoldersInList', dispCb.checked);
// reload the entire file list (and strip) in one go:
loadFileList(window.currentFolder);
inlineCb.addEventListener('change', () => {
window.showInlineFolders = inlineCb.checked;
localStorage.setItem('showInlineFolders', inlineCb.checked);
if (typeof window.loadFileList === 'function') {
window.loadFileList(window.currentFolder || 'root');
}
});
// wire up imageinput change
@@ -425,6 +510,18 @@ export async function openUserPanel() {
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
// sync display toggles from localStorage
const stripCb = modal.querySelector('#showFoldersInList');
const inlineCb = modal.querySelector('#showInlineFolders');
if (stripCb) {
const storedStrip = localStorage.getItem('showFoldersInList');
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
}
if (inlineCb) {
const storedInline = localStorage.getItem('showInlineFolders');
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
}
}
// show

View File

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

View File

@@ -72,6 +72,41 @@ function animateVerticalSlide(card) {
}, 260);
}
function createCardGhost(card, rect, opts) {
const options = opts || {};
const scale = typeof options.scale === 'number' ? options.scale : 1;
const opacity = typeof options.opacity === 'number' ? options.opacity : 1;
const ghost = card.cloneNode(true);
const cs = window.getComputedStyle(card);
// Give the ghost the same “card” chrome even though its attached to <body>
Object.assign(ghost.style, {
position: 'fixed',
left: rect.left + 'px',
top: rect.top + 'px',
width: rect.width + 'px',
height: rect.height + 'px',
margin: '0',
zIndex: '12000',
pointerEvents: 'none',
transformOrigin: 'center center',
transform: 'scale(' + scale + ')',
opacity: String(opacity),
// pull key visuals from the real card
backgroundColor: cs.backgroundColor || 'rgba(24,24,24,.96)',
borderRadius: cs.borderRadius || '',
boxShadow: cs.boxShadow || '',
borderColor: cs.borderColor || '',
borderWidth: cs.borderWidth || '',
borderStyle: cs.borderStyle || '',
backdropFilter: cs.backdropFilter || '',
});
return ghost;
}
// -------------------- header (icon+modal) --------------------
function saveHeaderOrder() {
const host = getHeaderDropArea();
@@ -98,7 +133,19 @@ function insertCardInHeader(card) {
if (!hidden) {
hidden = document.createElement('div');
hidden.id = 'hiddenCardsContainer';
hidden.style.display = 'none';
// Park cards offscreen but keep them rendered so modals/layout still work
Object.assign(hidden.style, {
position: 'absolute',
left: '-9999px',
top: '0',
width: '0',
height: '0',
overflow: 'visible',
pointerEvents: 'none'
// **NO** display:none here
});
document.body.appendChild(hidden);
}
if (card.parentNode?.id !== 'hiddenCardsContainer') hidden.appendChild(card);
@@ -177,7 +224,12 @@ function insertCardInHeader(card) {
iconButton.addEventListener('click', (e) => {
e.stopPropagation();
isLocked = !isLocked;
if (isLocked) showModal(); else hideModal();
iconButton.classList.toggle('is-locked', isLocked);
if (isLocked) {
showModal();
} else {
hideModal();
}
});
host.appendChild(iconButton);
@@ -325,6 +377,234 @@ function hideHeaderDockPersistent() {
}
}
function animateCardsIntoHeaderAndThen(done) {
const sb = getSidebar();
const top = getTopZone();
const liveCards = [];
if (sb) liveCards.push(...sb.querySelectorAll('#uploadCard, #folderManagementCard'));
if (top) liveCards.push(...top.querySelectorAll('#uploadCard, #folderManagementCard'));
if (!liveCards.length) {
done();
return;
}
// Snapshot their current positions before we move the real DOM
const snapshots = liveCards.map(card => {
const rect = card.getBoundingClientRect();
return { card, rect };
});
// Show dock so icons exist / have positions
showHeaderDockPersistent();
// Move real cards into header (hidden container + icons)
snapshots.forEach(({ card }) => {
try { insertCardInHeader(card); } catch {}
});
const ghosts = [];
snapshots.forEach(({ card, rect }) => {
// remember the size for the expand animation later
card.dataset.lastWidth = String(rect.width);
card.dataset.lastHeight = String(rect.height);
const iconBtn = card.headerIconButton;
if (!iconBtn) return;
const iconRect = iconBtn.getBoundingClientRect();
const ghost = createCardGhost(card, rect, { scale: 1, opacity: 1 });
ghost.id = card.id + '-ghost-collapse';
ghost.classList.add('card-collapse-ghost');
ghost.style.transition = 'transform 0.22s ease-out, opacity 0.22s ease-out';
document.body.appendChild(ghost);
ghosts.push({ ghost, from: rect, to: iconRect });
});
if (!ghosts.length) {
done();
return;
}
requestAnimationFrame(() => {
ghosts.forEach(({ ghost, from, to }) => {
const fromCx = from.left + from.width / 2;
const fromCy = from.top + from.height / 2;
const toCx = to.left + to.width / 2;
const toCy = to.top + to.height / 2;
const dx = toCx - fromCx;
const dy = toCy - fromCy;
const rawScale = to.width / from.width;
const scale = Math.max(0.25, Math.min(0.5, rawScale * 0.9));
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
ghost.style.opacity = '0';
});
});
setTimeout(() => {
ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} });
done();
}, 260);
}
function resolveTargetZoneForExpand(cardId) {
const layout = readLayout();
const saved = layout[cardId];
const isUpload = (cardId === 'uploadCard');
// 🔒 If the user explicitly pinned this card to the HEADER,
// it should remain a header-only icon and NEVER fly out.
if (saved === ZONES.HEADER) {
return null; // caller will skip animation + placement
}
let zone = saved || null;
// No saved zone yet: mirror applyUserLayoutOrDefault defaults
if (!zone) {
if (isSmallScreen()) {
zone = isUpload ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
} else {
zone = ZONES.SIDEBAR;
}
}
// On small screens, anything targeting SIDEBAR gets lifted into the top cols
if (isSmallScreen() && zone === ZONES.SIDEBAR) {
zone = isUpload ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
}
return zone;
}
function getZoneHost(zoneId) {
switch (zoneId) {
case ZONES.SIDEBAR: return getSidebar();
case ZONES.TOP_LEFT: return getLeftCol();
case ZONES.TOP_RIGHT: return getRightCol();
default: return null;
}
}
// Animate cards "flying out" of header icons back into their zones.
function animateCardsOutOfHeaderThen(done) {
const header = getHeaderDropArea();
if (!header) { done(); return; }
const cards = getCards().filter(c => c && c.headerIconButton);
if (!cards.length) { done(); return; }
// Make sure target containers are visible so their rects are non-zero.
const sb = getSidebar();
const top = getTopZone();
if (sb) sb.style.display = '';
if (top) top.style.display = '';
const SAFE_TOP = 16; // minimum distance from top of viewport
const START_OFFSET_Y = 40; // how far BELOW the icon we start the ghost
const DEST_EXTRA_Y = 120; // how far down into the zone center we aim
const ghosts = [];
cards.forEach(card => {
const iconBtn = card.headerIconButton;
if (!iconBtn) return;
const zoneId = resolveTargetZoneForExpand(card.id);
if (!zoneId) return; // header-only card, stays as icon
const host = getZoneHost(zoneId);
if (!host) return;
const iconRect = iconBtn.getBoundingClientRect();
const zoneRect = host.getBoundingClientRect();
if (!zoneRect.width) return;
// Where the ghost "comes from" (near the icon)
const fromCx = iconRect.left + iconRect.width / 2;
const fromCy = iconRect.bottom + START_OFFSET_Y; // lower starting point
// Where we want it to "land" (roughly center of the zone, a bit down)
let toCx = zoneRect.left + zoneRect.width / 2;
let toCy = zoneRect.top + Math.min(zoneRect.height / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y);
// 🔹 If both cards are going to the sidebar, offset them so they don't stack
if (zoneId === ZONES.SIDEBAR) {
if (card.id === 'uploadCard') {
toCy -= 48; // a bit higher
} else if (card.id === 'folderManagementCard') {
toCy += 48; // a bit lower
}
}
// Try to match the real card size we captured during collapse
const savedW = parseFloat(card.dataset.lastWidth || '');
const savedH = parseFloat(card.dataset.lastHeight || '');
const targetWidth = !Number.isNaN(savedW)
? savedW
: Math.min(280, Math.max(220, zoneRect.width * 0.85));
const targetHeight = !Number.isNaN(savedH) ? savedH : 190;
// Make sure the top of the ghost never goes above SAFE_TOP
const startTop = Math.max(SAFE_TOP, fromCy - targetHeight / 2);
// Build a rect for our ghost and use createCardGhost so we KEEP bg/border/shadow.
const ghostRect = {
left: fromCx - targetWidth / 2,
top: startTop,
width: targetWidth,
height: targetHeight
};
const ghost = createCardGhost(card, ghostRect, { scale: 0.7, opacity: 0 });
ghost.id = card.id + '-ghost-expand';
ghost.classList.add('card-expand-ghost');
// Override transform/transition for our flight animation
ghost.style.transform = 'translate(0,0) scale(0.7)';
ghost.style.transition = 'transform 0.25s ease-out, opacity 0.25s ease-out';
document.body.appendChild(ghost);
ghosts.push({
ghost,
from: { cx: fromCx, cy: fromCy },
to: { cx: toCx, cy: toCy },
zoneId
});
});
if (!ghosts.length) {
done();
return;
}
// Kick off the flight on the next frame
requestAnimationFrame(() => {
ghosts.forEach(({ ghost, from, to }) => {
const dx = to.cx - from.cx;
const dy = to.cy - from.cy;
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(1)`;
ghost.style.opacity = '1';
});
});
// Clean up ghosts and then do real layout restore
setTimeout(() => {
ghosts.forEach(({ ghost }) => {
try { ghost.remove(); } catch {}
});
done();
}, 280); // just over the 0.25s transition
}
// -------------------- zones toggle (collapse to header) --------------------
function isZonesCollapsed() { return localStorage.getItem('zonesCollapsed') === '1'; }
@@ -340,30 +620,73 @@ function applyCollapsedBodyClass() {
}
function setZonesCollapsed(collapsed) {
localStorage.setItem('zonesCollapsed', collapsed ? '1' : '0');
const currently = isZonesCollapsed();
if (collapsed === currently) return;
if (collapsed) {
// Move ALL cards to header icons (transient) regardless of where they were.
getCards().forEach(insertCardInHeader);
showHeaderDockPersistent();
const sb = getSidebar();
if (sb) sb.style.display = 'none';
// ---- COLLAPSE: immediately expand file area, then animate cards up into header ----
localStorage.setItem('zonesCollapsed', '1');
// File list area expands right away (no delay)
applyCollapsedBodyClass();
ensureZonesToggle();
updateZonesToggleUI();
document.dispatchEvent(
new CustomEvent('zones:collapsed-changed', { detail: { collapsed: true } })
);
try {
animateCardsIntoHeaderAndThen(() => {
const sb = getSidebar();
if (sb) sb.style.display = 'none';
updateSidebarVisibility();
updateTopZoneLayout();
showHeaderDockPersistent();
});
} catch (e) {
console.warn('[zones] collapse animation failed, collapsing instantly', e);
// Fallback: old instant behavior
getCards().forEach(insertCardInHeader);
showHeaderDockPersistent();
updateSidebarVisibility();
updateTopZoneLayout();
}
} else {
// Restore saved layout + rebuild header icons only for HEADER-assigned cards
applyUserLayoutOrDefault();
loadHeaderOrder();
hideHeaderDockPersistent();
// ---- EXPAND: immediately shrink file area, then animate cards out of header ----
localStorage.setItem('zonesCollapsed', '0');
// File list shrinks back right away
applyCollapsedBodyClass();
ensureZonesToggle();
updateZonesToggleUI();
document.dispatchEvent(
new CustomEvent('zones:collapsed-changed', { detail: { collapsed: false } })
);
try {
animateCardsOutOfHeaderThen(() => {
// After ghosts land, put the REAL cards back into their proper zones
applyUserLayoutOrDefault();
loadHeaderOrder();
hideHeaderDockPersistent();
updateSidebarVisibility();
updateTopZoneLayout();
});
} catch (e) {
console.warn('[zones] expand animation failed, expanding instantly', e);
// Fallback: just restore layout
applyUserLayoutOrDefault();
loadHeaderOrder();
hideHeaderDockPersistent();
updateSidebarVisibility();
updateTopZoneLayout();
}
}
updateSidebarVisibility();
updateTopZoneLayout();
ensureZonesToggle();
updateZonesToggleUI();
applyCollapsedBodyClass();
document.dispatchEvent(new CustomEvent('zones:collapsed-changed', { detail: { collapsed: isZonesCollapsed() } }));
}
function getHeaderHost() {
let host = document.querySelector('.header-container .header-left');
if (!host) host = document.querySelector('.header-container');
@@ -371,6 +694,36 @@ function getHeaderHost() {
return host || document.body;
}
function animateZonesCollapseAndThen(done) {
const sb = getSidebar();
const top = getTopZone();
const cards = [];
if (sb) cards.push(...sb.querySelectorAll('#uploadCard, #folderManagementCard'));
if (top) cards.push(...top.querySelectorAll('#uploadCard, #folderManagementCard'));
if (!cards.length) {
done();
return;
}
// quick "rise away" animation
cards.forEach(card => {
card.style.transition = 'transform 0.18s ease-out, opacity 0.18s ease-out';
card.style.transform = 'translateY(-10px)';
card.style.opacity = '0';
});
setTimeout(() => {
cards.forEach(card => {
card.style.transition = '';
card.style.transform = '';
card.style.opacity = '';
});
done();
}, 190);
}
function ensureZonesToggle() {
const host = getHeaderHost();
if (!host) return;
@@ -605,7 +958,8 @@ function makeCardDraggable(card) {
const sb = getSidebar();
if (sb) {
sb.classList.add('active', 'highlight');
if (!isZonesCollapsed()) sb.style.display = 'block';
// Always show sidebar as a drop target while dragging
sb.style.display = 'block';
ensureSidebarPlaceholder(); // make empty sidebar easy to drop into
}

View File

@@ -20,6 +20,27 @@ export function handleDeleteSelected(e) {
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
}
const FILE_MODAL_IDS = [
'deleteFilesModal',
'downloadZipModal',
'downloadProgressModal',
'createFileModal',
'downloadFileModal',
'copyFilesModal',
'moveFilesModal',
'renameFileModal',
'createFolderModal', // if this exists in your HTML
];
function portalFileModalsToBody() {
FILE_MODAL_IDS.forEach(id => {
const el = document.getElementById(id);
if (el && el.parentNode !== document.body) {
document.body.appendChild(el);
}
});
}
// --- Upload modal "portal" support ---
let _uploadCardSentinel = null;
@@ -818,6 +839,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Expose initFileActions so it can be called from fileManager.js
export function initFileActions() {
portalFileModalsToBody();
const deleteSelectedBtn = document.getElementById("deleteSelectedBtn");
if (deleteSelectedBtn) {
deleteSelectedBtn.replaceWith(deleteSelectedBtn.cloneNode(true));

View File

@@ -240,16 +240,29 @@ window.addEventListener('resize', () => {
if (strip) applyFolderStripLayout(strip);
});
// Listen once: update strip + tree when folder color changes
// Listen once: update strip + tree + inline rows when folder color changes
window.addEventListener('folderColorChanged', (e) => {
const { folder } = e.detail || {};
if (!folder) return;
// Update the strip (if that folder is currently shown)
// 1) Update the strip (if that folder is currently shown)
repaintStripIcon(folder);
// And refresh the tree icon too (existing function)
// 2) Refresh the tree icon (existing function)
try { refreshFolderIcon(folder); } catch { }
// 3) Repaint any inline folder rows in the file table
try {
const safeFolder = CSS.escape(folder);
document
.querySelectorAll(`#fileList tr.folder-row[data-folder="${safeFolder}"]`)
.forEach(row => {
// reuse the same helper we used when injecting inline rows
attachStripIconAsync(row, folder, 28);
});
} catch {
// CSS.escape might not exist on very old browsers; fail silently
}
});
// Hide "Edit" for files >10 MiB
@@ -259,11 +272,25 @@ const MAX_EDIT_BYTES = 10 * 1024 * 1024;
let __fileListReqSeq = 0;
window.itemsPerPage = parseInt(
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '50',
10
);
window.currentPage = window.currentPage || 1;
window.viewMode = localStorage.getItem("viewMode") || "table";
window.currentSubfolders = window.currentSubfolders || [];
// Default folder display settings from localStorage
try {
const storedStrip = localStorage.getItem('showFoldersInList');
const storedInline = localStorage.getItem('showInlineFolders');
window.showFoldersInList = storedStrip === null ? true : storedStrip === 'true';
window.showInlineFolders = storedInline === null ? true : storedInline === 'true';
} catch {
// if localStorage blows up, fall back to both enabled
window.showFoldersInList = true;
window.showInlineFolders = true;
}
// Global flag for advanced search mode.
window.advancedSearchEnabled = false;
@@ -387,7 +414,6 @@ function attachStripIconAsync(hostEl, fullPath, size = 28) {
const back = _lighten(hex, 14);
const stroke = _darken(hex, 22);
// apply vars on the tile (or icon span)
hostEl.style.setProperty('--filr-folder-front', front);
hostEl.style.setProperty('--filr-folder-back', back);
hostEl.style.setProperty('--filr-folder-stroke', stroke);
@@ -395,15 +421,26 @@ function attachStripIconAsync(hostEl, fullPath, size = 28) {
const iconSpan = hostEl.querySelector('.folder-svg');
if (!iconSpan) return;
// 1) initial "empty" icon
iconSpan.dataset.kind = 'empty';
iconSpan.innerHTML = folderSVG('empty'); // size is baked into viewBox; add a size arg if you prefer
iconSpan.innerHTML = folderSVG('empty');
// make sure this brand-new SVG is sized correctly
try { syncFolderIconSizeToRowHeight(); } catch {}
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(fullPath)}&t=${Date.now()}`;
_fetchJSONWithTimeout(url, 2500).then(({ folders = 0, files = 0 }) => {
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
iconSpan.dataset.kind = 'paper';
iconSpan.innerHTML = folderSVG('paper');
}
}).catch(() => { });
_fetchJSONWithTimeout(url, 2500)
.then(({ folders = 0, files = 0 }) => {
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
// 2) swap to "paper" icon
iconSpan.dataset.kind = 'paper';
iconSpan.innerHTML = folderSVG('paper');
// re-apply sizing to this new SVG too
try { syncFolderIconSizeToRowHeight(); } catch {}
}
})
.catch(() => { /* ignore */ });
}
/* -----------------------------
@@ -426,6 +463,56 @@ async function safeJson(res) {
}
return body ?? {};
}
// --- Folder capabilities + owner cache ----------------------
const _folderCapsCache = new Map();
async function fetchFolderCaps(folder) {
if (!folder) return null;
if (_folderCapsCache.has(folder)) {
return _folderCapsCache.get(folder);
}
try {
const res = await fetch(
`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`,
{ credentials: 'include' }
);
const data = await safeJson(res);
_folderCapsCache.set(folder, data || null);
if (data && (data.owner || data.user)) {
_folderOwnerCache.set(folder, data.owner || data.user || "");
}
return data || null;
} catch {
_folderCapsCache.set(folder, null);
return null;
}
}
// --- Folder owner cache + helper ----------------------
const _folderOwnerCache = new Map();
async function fetchFolderOwner(folder) {
if (!folder) return "";
if (_folderOwnerCache.has(folder)) {
return _folderOwnerCache.get(folder);
}
try {
const res = await fetch(
`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`,
{ credentials: 'include' }
);
const data = await safeJson(res);
const owner = data && (data.owner || data.user || "");
_folderOwnerCache.set(folder, owner || "");
return owner || "";
} catch {
_folderOwnerCache.set(folder, "");
return "";
}
}
// ---- Viewed badges (table + gallery) ----
// ---------- Badge factory (center text vertically) ----------
function makeBadge(state) {
@@ -847,7 +934,7 @@ export async function loadFileList(folderParam) {
if (!summaryElem) {
summaryElem = document.createElement("div");
summaryElem.id = "fileSummary";
summaryElem.style.cssText = "float:right; margin:0 60px 0 auto; font-size:0.9em;";
summaryElem.style.cssText = "float:right; margin:0 30px 0 auto; font-size:0.9em;";
actionsContainer.appendChild(summaryElem);
}
summaryElem.style.display = "block";
@@ -917,6 +1004,13 @@ export async function loadFileList(folderParam) {
document.documentElement.style.setProperty("--file-row-height", v + "px");
localStorage.setItem("rowHeight", v);
rowValue.textContent = v + "px";
// mark compact mode for very low heights
if (v <= 32) {
document.documentElement.setAttribute('data-row-compact', '1');
} else {
document.documentElement.removeAttribute('data-row-compact');
}
syncFolderIconSizeToRowHeight();
};
}
}
@@ -966,6 +1060,9 @@ export async function loadFileList(folderParam) {
return !hidden.has(lower) && !lower.startsWith("resumable_");
});
// Expose for inline folder rows in table view
window.currentSubfolders = subfolders;
let strip = document.getElementById("folderStripContainer");
if (!strip) {
strip = document.createElement("div");
@@ -976,6 +1073,11 @@ export async function loadFileList(folderParam) {
// NEW: paged + responsive strip
renderFolderStripPaged(strip, subfolders);
// Re-render table view once folders are known so they appear inline above files
if (window.viewMode === "table" && reqId === __fileListReqSeq) {
renderFileTable(folder);
}
} catch {
// ignore folder errors; rows already rendered
}
@@ -1000,24 +1102,456 @@ export async function loadFileList(folderParam) {
}
}
function injectInlineFolderRows(fileListContent, folder, pageSubfolders) {
const table = fileListContent.querySelector('table.filr-table');
// Use the paged subfolders if provided, otherwise fall back to all
const subfolders = Array.isArray(pageSubfolders) && pageSubfolders.length
? pageSubfolders
: (Array.isArray(window.currentSubfolders) ? window.currentSubfolders : []);
if (!table || !subfolders.length) return;
const thead = table.tHead;
const tbody = table.tBodies && table.tBodies[0];
if (!thead || !tbody) return;
const headerRow = thead.rows[0];
if (!headerRow) return;
const headerCells = Array.from(headerRow.cells);
const colCount = headerCells.length;
// --- Column indices -------------------------------------------------------
let checkboxIdx = headerCells.findIndex(th =>
th.classList.contains("checkbox-col") ||
th.querySelector('input[type="checkbox"]')
);
let nameIdx = headerCells.findIndex(th =>
(th.dataset && th.dataset.column === "name") ||
/\bname\b/i.test((th.textContent || "").trim())
);
if (nameIdx < 0) {
nameIdx = Math.min(1, colCount - 1); // fallback to 2nd col
}
let sizeIdx = headerCells.findIndex(th =>
(th.dataset && (th.dataset.column === "size" || th.dataset.column === "filesize")) ||
/\bsize\b/i.test((th.textContent || "").trim())
);
if (sizeIdx < 0) sizeIdx = -1;
let uploaderIdx = headerCells.findIndex(th =>
(th.dataset && th.dataset.column === "uploader") ||
/\buploader\b/i.test((th.textContent || "").trim())
);
if (uploaderIdx < 0) uploaderIdx = -1;
let actionsIdx = headerCells.findIndex(th =>
(th.dataset && th.dataset.column === "actions") ||
/\bactions?\b/i.test((th.textContent || "").trim()) ||
/\bactions?-col\b/i.test(th.className || "")
);
if (actionsIdx < 0) actionsIdx = -1;
// Remove any previous folder rows
tbody.querySelectorAll("tr.folder-row").forEach(tr => tr.remove());
const firstDataRow = tbody.firstElementChild;
subfolders.forEach(sf => {
const tr = document.createElement("tr");
tr.classList.add("folder-row");
tr.dataset.folder = sf.full;
for (let i = 0; i < colCount; i++) {
const td = document.createElement("td");
// *** copy header classes so responsive breakpoints match file rows ***
// but strip Bootstrap margin helpers (ml-2 / mx-2) so we don't get a big gap
const headerClass = headerCells[i] && headerCells[i].className;
if (headerClass) {
td.className = headerClass;
td.classList.remove("ml-2", "mx-2");
}
// 1) icon / checkbox column
if (i === checkboxIdx) {
td.classList.add("folder-icon-cell");
td.style.textAlign = "left";
td.style.verticalAlign = "middle";
const iconSpan = document.createElement("span");
iconSpan.className = "folder-svg folder-row-icon";
td.appendChild(iconSpan);
// 2) name column
} else if (i === nameIdx) {
td.classList.add("name-cell", "file-name-cell", "folder-name-cell");
const wrap = document.createElement("div");
wrap.className = "folder-row-inner";
const nameSpan = document.createElement("span");
nameSpan.className = "folder-row-name";
nameSpan.textContent = sf.name || sf.full;
const metaSpan = document.createElement("span");
metaSpan.className = "folder-row-meta";
metaSpan.textContent = ""; // "(15 folders, 19 files)" later
wrap.appendChild(nameSpan);
wrap.appendChild(metaSpan);
td.appendChild(wrap);
// 3) size column
} else if (i === sizeIdx) {
td.classList.add("folder-size-cell");
td.textContent = "…"; // placeholder until we load stats
// 4) uploader / owner column
} else if (i === uploaderIdx) {
td.classList.add("uploader-cell", "folder-uploader-cell");
td.textContent = ""; // filled asynchronously with owner
// 5) actions column
} else if (i === actionsIdx) {
td.classList.add("folder-actions-cell");
const group = document.createElement("div");
group.className = "btn-group btn-group-sm folder-actions-group";
group.setAttribute("role", "group");
group.setAttribute("aria-label", "File actions");
const makeActionBtn = (iconName, titleKey, btnClass, actionKey, handler) => {
const btn = document.createElement("button");
btn.type = "button";
// base classes same size as file actions
btn.className = `btn ${btnClass} py-1`;
// kill any Bootstrap margin helpers that got passed in
btn.classList.remove("ml-2", "mx-2");
btn.setAttribute("data-folder-action", actionKey);
btn.setAttribute("data-i18n-title", titleKey);
btn.title = t(titleKey);
const icon = document.createElement("i");
icon.className = "material-icons";
icon.textContent = iconName;
btn.appendChild(icon);
btn.addEventListener("click", e => {
e.stopPropagation();
window.currentFolder = sf.full;
try { localStorage.setItem("lastOpenedFolder", sf.full); } catch {}
handler();
});
// start disabled; caps logic will enable
btn.disabled = true;
btn.style.pointerEvents = "none";
btn.style.opacity = "0.5";
group.appendChild(btn);
};
makeActionBtn("drive_file_move", "move_folder", "btn-warning folder-move-btn", "move", () => openMoveFolderUI());
makeActionBtn("palette", "color_folder", "btn-color-folder","color", () => openColorFolderModal(sf.full));
makeActionBtn("drive_file_rename_outline", "rename_folder", "btn-warning folder-rename-btn", "rename", () => openRenameFolderModal());
makeActionBtn("share", "share_folder", "btn-secondary", "share", () => openFolderShareModal(sf.full));
td.appendChild(group);
}
// IMPORTANT: always append the cell, no matter which column we're in
tr.appendChild(td);
}
// click → navigate, same as before
tr.addEventListener("click", e => {
if (e.button !== 0) return;
const dest = sf.full;
if (!dest) return;
window.currentFolder = dest;
try { localStorage.setItem("lastOpenedFolder", dest); } catch { }
updateBreadcrumbTitle(dest);
document.querySelectorAll(".folder-option.selected")
.forEach(o => o.classList.remove("selected"));
const treeNode = document.querySelector(
`.folder-option[data-folder="${CSS.escape(dest)}"]`
);
if (treeNode) treeNode.classList.add("selected");
const strip = document.getElementById("folderStripContainer");
if (strip) {
strip.querySelectorAll(".folder-item.selected")
.forEach(i => i.classList.remove("selected"));
const stripItem = strip.querySelector(
`.folder-item[data-folder="${CSS.escape(dest)}"]`
);
if (stripItem) stripItem.classList.add("selected");
}
loadFileList(dest);
});
// DnD + context menu keep existing logic, but also add a visual highlight
tr.addEventListener("dragover", e => {
folderDragOverHandler(e);
tr.classList.add("folder-row-droptarget");
});
tr.addEventListener("dragleave", e => {
folderDragLeaveHandler(e);
tr.classList.remove("folder-row-droptarget");
});
tr.addEventListener("drop", e => {
folderDropHandler(e);
tr.classList.remove("folder-row-droptarget");
});
tr.addEventListener("contextmenu", e => {
e.preventDefault();
e.stopPropagation();
const dest = sf.full;
if (!dest) return;
window.currentFolder = dest;
try { localStorage.setItem("lastOpenedFolder", dest); } catch { }
const menuItems = [
{ label: t("create_folder"), action: () => document.getElementById("createFolderModal").style.display = "block" },
{ label: t("move_folder"), action: () => openMoveFolderUI() },
{ label: t("rename_folder"), action: () => openRenameFolderModal() },
{ label: t("color_folder"), action: () => openColorFolderModal(dest) },
{ label: t("folder_share"), action: () => openFolderShareModal(dest) },
{ label: t("delete_folder"), action: () => openDeleteFolderModal() }
];
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
});
// insert row above first file row
tbody.insertBefore(tr, firstDataRow || null);
// ----- ICON: color + alignment (size is driven by row height) -----
attachStripIconAsync(tr, sf.full);
const iconSpan = tr.querySelector(".folder-row-icon");
if (iconSpan) {
iconSpan.style.display = "inline-flex";
iconSpan.style.alignItems = "center";
iconSpan.style.justifyContent = "flex-start";
iconSpan.style.marginLeft = "0px"; // small left nudge
iconSpan.style.marginTop = "0px"; // small down nudge
}
// ----- FOLDER STATS + OWNER + CAPS (keep your existing code below here) -----
const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1;
const nameCellIndex = (nameIdx >= 0 && nameIdx < tr.cells.length) ? nameIdx : -1;
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(sf.full)}&t=${Date.now()}`;
_fetchJSONWithTimeout(url, 2500).then(stats => {
if (!stats) return;
const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
const bytes = Number.isFinite(stats.bytes)
? stats.bytes
: (Number.isFinite(stats.sizeBytes) ? stats.sizeBytes : null);
let pieces = [];
if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
if (filesCount) pieces.push(`${filesCount} file${filesCount === 1 ? "" : "s"}`);
if (!pieces.length) pieces.push("0 items");
const countLabel = pieces.join(", ");
if (nameCellIndex >= 0) {
const nameCell = tr.cells[nameCellIndex];
if (nameCell) {
const metaSpan = nameCell.querySelector(".folder-row-meta");
if (metaSpan) metaSpan.textContent = ` (${countLabel})`;
}
}
if (sizeCellIndex >= 0) {
const sizeCell = tr.cells[sizeCellIndex];
if (sizeCell) {
let sizeLabel = "—";
if (bytes != null && bytes >= 0) {
sizeLabel = formatSize(bytes);
}
sizeCell.textContent = sizeLabel;
sizeCell.title = `${countLabel}${bytes != null && bytes >= 0 ? " • " + sizeLabel : ""}`;
}
}
}).catch(() => {
if (sizeCellIndex >= 0) {
const sizeCell = tr.cells[sizeCellIndex];
if (sizeCell && !sizeCell.textContent) sizeCell.textContent = "—";
}
});
// OWNER + action permissions
if (uploaderIdx >= 0 || actionsIdx >= 0) {
fetchFolderCaps(sf.full).then(caps => {
if (!caps || !document.body.contains(tr)) return;
if (uploaderIdx >= 0 && uploaderIdx < tr.cells.length) {
const uploaderCell = tr.cells[uploaderIdx];
if (uploaderCell) {
const owner = caps.owner || caps.user || "";
uploaderCell.textContent = owner || "";
}
}
if (actionsIdx >= 0 && actionsIdx < tr.cells.length) {
const actCell = tr.cells[actionsIdx];
if (!actCell) return;
actCell.querySelectorAll('button[data-folder-action]').forEach(btn => {
const action = btn.getAttribute('data-folder-action');
let enabled = false;
switch (action) {
case "move":
enabled = !!caps.canMoveFolder;
break;
case "color":
enabled = !!caps.canRename; // same gate as tree “color” button
break;
case "rename":
enabled = !!caps.canRename;
break;
case "share":
enabled = !!caps.canShareFolder;
break;
}
if (enabled === undefined) {
enabled = true; // fallback so admin still gets buttons even if a flag is missing
}
if (enabled) {
btn.disabled = false;
btn.style.pointerEvents = "";
btn.style.opacity = "";
} else {
btn.disabled = true;
btn.style.pointerEvents = "none";
btn.style.opacity = "0.5";
}
});
}
}).catch(() => { /* ignore */ });
}
});
syncFolderIconSizeToRowHeight();
}
function syncFolderIconSizeToRowHeight() {
const cs = getComputedStyle(document.documentElement);
const raw = cs.getPropertyValue('--file-row-height') || '48px';
const rowH = parseInt(raw, 10) || 60;
const FUDGE = 5;
const MAX_GROWTH_ROW = 44; // after this, stop growing the icon
const BASE_ROW_FOR_OFFSET = 40; // where icon looks centered
const OFFSET_FACTOR = 0.25;
// cap growth for size, like you already do
const effectiveRow = Math.min(rowH, MAX_GROWTH_ROW);
const boxSize = Math.max(25, Math.min(35, effectiveRow - 20 + FUDGE));
const scale = 1.20;
// use your existing offset curve
const clampedForOffset = Math.max(30, Math.min(60, rowH));
let offsetY = (clampedForOffset - BASE_ROW_FOR_OFFSET) * OFFSET_FACTOR;
// 3044: untouched (you said this range is perfect)
// 4560: same curve, but shifted up slightly
if (rowH > 53) {
offsetY -= 3;
}
document.querySelectorAll('#fileList .folder-row-icon').forEach(iconSpan => {
iconSpan.style.width = boxSize + 'px';
iconSpan.style.height = boxSize + 'px';
iconSpan.style.overflow = 'visible';
const svg = iconSpan.querySelector('svg');
if (!svg) return;
svg.setAttribute('width', String(boxSize));
svg.setAttribute('height', String(boxSize));
svg.style.transformOrigin = 'left center';
svg.style.transform = `translateY(${offsetY}px) scale(${scale})`;
});
}
/**
* Render table view
*/
export function renderFileTable(folder, container, subfolders) {
const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "50", 10);
let currentPage = window.currentPage || 1;
// Files (filtered by search)
const filteredFiles = searchFiles(searchTerm);
const totalFiles = filteredFiles.length;
const totalPages = Math.ceil(totalFiles / itemsPerPageSetting);
// Inline folders: sort once (Explorer-style A→Z)
const allSubfolders = Array.isArray(window.currentSubfolders)
? window.currentSubfolders
: [];
const subfoldersSorted = [...allSubfolders].sort((a, b) =>
(a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" })
);
const totalFiles = filteredFiles.length;
const totalFolders = subfoldersSorted.length;
const totalRows = totalFiles + totalFolders;
const hasFolders = totalFolders > 0;
// Pagination is now over (folders + files)
const totalPages = totalRows > 0
? Math.ceil(totalRows / itemsPerPageSetting)
: 1;
if (currentPage > totalPages) {
currentPage = totalPages > 0 ? totalPages : 1;
currentPage = totalPages;
window.currentPage = currentPage;
}
const startRow = (currentPage - 1) * itemsPerPageSetting;
const endRow = Math.min(startRow + itemsPerPageSetting, totalRows);
// Figure out which folders + files belong to THIS page
const pageFolders = [];
const pageFiles = [];
for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) {
if (rowIndex < totalFolders) {
pageFolders.push(subfoldersSorted[rowIndex]);
} else {
const fileIdx = rowIndex - totalFolders;
const file = filteredFiles[fileIdx];
if (file) pageFiles.push(file);
}
}
// Stable id per file row on this page
const rowIdFor = (file, idx) =>
`${encodeURIComponent(file.name)}-p${currentPage}-${idx}`;
// We pass a harmless "base" string to keep buildFileTableRow happy,
// then we will FIX the preview/thumbnail URLs to the API below.
const fakeBase = "#/";
@@ -1040,19 +1574,16 @@ export function renderFileTable(folder, container, subfolders) {
return `<table class="filr-table"${attrs}>`;
});
const startIndex = (currentPage - 1) * itemsPerPageSetting;
const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles);
let rowsHTML = "<tbody>";
if (totalFiles > 0) {
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
// Build row with a neutral base, then correct the links/preview below.
// Give the row an ID so we can patch attributes safely
const idSafe = encodeURIComponent(file.name) + "-" + (startIndex + idx);
let rowsHTML = "<tbody>";
if (pageFiles.length > 0) {
pageFiles.forEach((file, idx) => {
const rowKey = rowIdFor(file, idx);
let rowHTML = buildFileTableRow(file, fakeBase);
// add row id + data-file-name, and ensure the name cell also has "name-cell"
rowHTML = rowHTML
.replace("<tr", `<tr id="file-row-${idSafe}" data-file-name="${escapeHTML(file.name)}"`)
.replace("<tr", `<tr id="file-row-${rowKey}" data-file-name="${escapeHTML(file.name)}"`)
.replace('class="file-name-cell"', 'class="file-name-cell name-cell"');
let tagBadgesHTML = "";
@@ -1063,54 +1594,63 @@ export function renderFileTable(folder, container, subfolders) {
});
tagBadgesHTML += "</div>";
}
rowsHTML += rowHTML.replace(
/(<td\s+class="[^"]*\bfile-name-cell\b[^"]*">)([\s\S]*?)(<\/td>)/,
(m, open, inner, close) => {
// keep the original filename content, then add your tag badges, then close
return `${open}<span class="filename-text">${inner}</span>${tagBadgesHTML}${close}`;
}
);
});
} else {
rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`;
} else if (!hasFolders && totalFiles === 0) {
// Only show "No files found" if there are no folders either
rowsHTML += `<tr><td colspan="8">${t("no_files_found") || "No files found."}</td></tr>`;
}
rowsHTML += "</tbody></table>";
const bottomControlsHTML = buildBottomControls(itemsPerPageSetting);
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
// Inject inline folder rows for THIS page (Explorer-style)
if (window.showInlineFolders !== false && pageFolders.length) {
injectInlineFolderRows(fileListContent, folder, pageFolders);
}
wireSelectAll(fileListContent);
// PATCH each row's preview/thumb to use the secure API URLs
if (totalFiles > 0) {
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
const rowEl = document.getElementById(`file-row-${encodeURIComponent(file.name)}-${startIndex + idx}`);
if (!rowEl) return;
const previewUrl = apiFileUrl(file.folder || folder, file.name, true);
// Preview button dataset
const previewBtn = rowEl.querySelector(".preview-btn");
if (previewBtn) {
previewBtn.dataset.previewUrl = previewUrl;
previewBtn.dataset.previewName = file.name;
}
// Thumbnail (if present)
const thumbImg = rowEl.querySelector("img");
if (thumbImg) {
thumbImg.src = previewUrl;
thumbImg.setAttribute("data-cache-key", previewUrl);
}
// Any anchor that might have been built to point at a file path
rowEl.querySelectorAll('a[href]').forEach(a => {
// Only rewrite obvious file anchors (ignore actions with '#', 'javascript:', etc.)
if (/^#|^javascript:/i.test(a.getAttribute('href') || '')) return;
a.href = previewUrl;
// PATCH each row's preview/thumb to use the secure API URLs
if (pageFiles.length > 0) {
pageFiles.forEach((file, idx) => {
const rowKey = rowIdFor(file, idx);
const rowEl = document.getElementById(`file-row-${rowKey}`);
if (!rowEl) return;
const previewUrl = apiFileUrl(file.folder || folder, file.name, true);
// Preview button dataset
const previewBtn = rowEl.querySelector(".preview-btn");
if (previewBtn) {
previewBtn.dataset.previewUrl = previewUrl;
previewBtn.dataset.previewName = file.name;
}
// Thumbnail (if present)
const thumbImg = rowEl.querySelector("img");
if (thumbImg) {
thumbImg.src = previewUrl;
thumbImg.setAttribute("data-cache-key", previewUrl);
}
// Any anchor that might have been built to point at a file path
rowEl.querySelectorAll('a[href]').forEach(a => {
// Only rewrite obvious file anchors (ignore actions with '#', 'javascript:', etc.)
if (/^#|^javascript:/i.test(a.getAttribute('href') || '')) return;
a.href = previewUrl;
});
});
});
}
}
fileListContent.querySelectorAll('.folder-item').forEach(el => {
el.addEventListener('click', () => loadFileList(el.dataset.folder));
@@ -1147,7 +1687,7 @@ export function renderFileTable(folder, container, subfolders) {
renderFileTable(folder, container);
});
// Row-select
// Row-select (only file rows have checkboxes; folder rows are ignored here)
fileListContent.querySelectorAll("tbody tr").forEach(row => {
row.addEventListener("click", e => {
const cb = row.querySelector(".file-checkbox");
@@ -1156,6 +1696,8 @@ export function renderFileTable(folder, container, subfolders) {
});
});
// Download buttons
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
btn.addEventListener("click", e => {
@@ -1248,12 +1790,15 @@ export function renderFileTable(folder, container, subfolders) {
});
updateFileActionButtons();
// Dragstart only for file rows (skip folder rows)
document.querySelectorAll("#fileList tbody tr").forEach(row => {
if (row.classList.contains("folder-row")) return;
row.setAttribute("draggable", "true");
import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
row.addEventListener("dragstart", module.fileDragStartHandler);
});
});
document.querySelectorAll(".download-btn, .edit-btn, .rename-btn").forEach(btn => {
btn.addEventListener("click", e => e.stopPropagation());
});

View File

@@ -239,7 +239,26 @@ function ensureMediaModal() {
</div>`;
document.body.appendChild(overlay);
// Ensure a container for tags next to the title (created once)
(function ensureTitleTagsContainer() {
const titleRow = overlay.querySelector('.media-title');
if (!titleRow) return;
let tagsEl = overlay.querySelector('.title-tags');
if (!tagsEl) {
tagsEl = document.createElement('div');
tagsEl.className = 'title-tags';
Object.assign(tagsEl.style, {
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
marginLeft: '6px',
maxHeight: '32px',
overflow: 'hidden',
});
titleRow.appendChild(tagsEl);
}
})();
// theme the close “×” for visibility + hover rules that match your site:
const closeBtn = overlay.querySelector("#closeFileModal");
function paintCloseBase() {
@@ -272,17 +291,46 @@ function ensureMediaModal() {
function setTitle(overlay, name) {
const textEl = overlay.querySelector('.title-text');
const iconEl = overlay.querySelector('.title-icon');
const tagsEl = overlay.querySelector('.title-tags');
// File name + tooltip
if (textEl) {
textEl.textContent = name || '';
textEl.setAttribute('title', name || '');
}
// File type icon
if (iconEl) {
iconEl.textContent = getIconForFile(name);
// keep the icon legible in both themes
const dark = document.documentElement.classList.contains('dark-mode');
iconEl.style.color = dark ? '#f5f5f5' : '#111111';
iconEl.style.opacity = dark ? '0.96' : '0.9';
}
// Tag badges next to the title
if (tagsEl) {
tagsEl.innerHTML = '';
let fileObj = null;
if (Array.isArray(fileData)) {
fileObj = fileData.find(f => f.name === name);
}
if (fileObj && Array.isArray(fileObj.tags) && fileObj.tags.length) {
fileObj.tags.forEach(tag => {
const badge = document.createElement('span');
badge.textContent = tag.name;
badge.style.backgroundColor = tag.color || '#444';
badge.style.color = '#fff';
badge.style.padding = '2px 6px';
badge.style.borderRadius = '999px';
badge.style.fontSize = '0.75rem';
badge.style.lineHeight = '1.2';
badge.style.whiteSpace = 'nowrap';
tagsEl.appendChild(badge);
});
}
}
}
// Topbar icon (theme-aware) used for image tools + video actions

View File

@@ -10,6 +10,29 @@ import { fetchWithCsrf } from './auth.js?v={{APP_QVER}}';
import { loadCsrfToken } from './appCore.js?v={{APP_QVER}}';
function detachFolderModalsToBody() {
const ids = [
'createFolderModal',
'deleteFolderModal',
'moveFolderModal',
'renameFolderModal',
];
ids.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
if (el.parentNode !== document.body) {
document.body.appendChild(el);
}
if (!el.style.zIndex) {
el.style.zIndex = '13000';
}
});
}
document.addEventListener('DOMContentLoaded', detachFolderModalsToBody);
const PAGE_LIMIT = 100;
/* ----------------------
@@ -230,23 +253,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 +975,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);
@@ -1688,6 +1734,7 @@ function bindFolderManagerContextMenu() {
Rename / Delete / Create hooks
----------------------*/
export function openRenameFolderModal() {
detachFolderModalsToBody();
const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") { showToast("Please select a valid folder to rename."); return; }
const parts = selectedFolder.split("/");
@@ -1758,6 +1805,7 @@ if (submitRename) submitRename.addEventListener("click", function (event) {
});
export function openDeleteFolderModal() {
detachFolderModalsToBody();
const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") { showToast("Please select a valid folder to delete."); return; }
const msgEl = document.getElementById("deleteFolderMessage");
@@ -1800,6 +1848,7 @@ if (confirmDelete) confirmDelete.addEventListener("click", async function () {
const createBtn = document.getElementById("createFolderBtn");
if (createBtn) createBtn.addEventListener("click", function () {
detachFolderModalsToBody();
const modal = document.getElementById("createFolderModal");
const input = document.getElementById("newFolderName");
if (modal) modal.style.display = "block";
@@ -1862,6 +1911,7 @@ if (submitCreate) submitCreate.addEventListener("click", async () => {
Move (modal) + Color carry + State migration as well
----------------------*/
export function openMoveFolderUI(sourceFolder) {
detachFolderModalsToBody();
const modal = document.getElementById('moveFolderModal');
const targetSel = document.getElementById('moveFolderTarget');
if (sourceFolder && sourceFolder !== 'root') window.currentFolder = sourceFolder;

View File

@@ -35,6 +35,8 @@ const translations = {
"tag_name": "Tag Name:",
"tag_color": "Tag Color:",
"save_tag": "Save Tag",
"no_tags_available": "No tags available",
"current_tags": "Current Tags",
"light_mode": "Light Mode",
"dark_mode": "Dark Mode",
"upload_instruction": "Drop files/folders here or click 'Choose files'",
@@ -268,7 +270,7 @@ const translations = {
"columns": "Columns",
"row_height": "Row Height",
"api_docs": "API Docs",
"show_folders_above_files": "Show folders above files",
"show_folders_above_files": "Show folder strip above list",
"display": "Display",
"create_file": "Create File",
"create_new_file": "Create New File",
@@ -331,7 +333,16 @@ const translations = {
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
"load_more_folders": "Load More Folders"
"load_more_folders": "Load More Folders",
"show_inline_folders": "Show folders as rows above files",
"name": "Name",
"size": "Size",
"modified": "Modified",
"created": "Created",
"owner": "Owner",
"hide_header_zoom_controls": "Hide header zoom controls",
"preview_not_available": "Preview is not available for this file type.",
"storage_pro_bundle_outdated": "Please upgrade to the latest FileRise Pro bundle to use the Storage explorer."
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

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

View File

@@ -39,6 +39,70 @@ function saveResumableDraftsAll(all) {
}
}
// --- Single file-picker trigger guard (prevents multiple OS dialogs) ---
let _lastFilePickerOpen = 0;
function triggerFilePickerOnce() {
const now = Date.now();
// ignore any extra calls within 400ms of the last open
if (now - _lastFilePickerOpen < 400) return;
_lastFilePickerOpen = now;
const fi = document.getElementById('file');
if (fi) {
fi.click();
}
}
// Wire the "Choose files" button so it always uses the guarded trigger
function wireChooseButton() {
const btn = document.getElementById('customChooseBtn');
if (!btn || btn.__uploadBound) return;
btn.__uploadBound = true;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation(); // don't let it bubble to the drop-area click handler
triggerFilePickerOnce();
});
}
function wireFileInputChange(fileInput) {
if (!fileInput || fileInput.__uploadChangeBound) return;
fileInput.__uploadChangeBound = true;
// For file picker, remove directory attributes so only files can be chosen.
fileInput.removeAttribute("webkitdirectory");
fileInput.removeAttribute("mozdirectory");
fileInput.removeAttribute("directory");
fileInput.setAttribute("multiple", "");
fileInput.addEventListener("change", async function () {
const files = Array.from(fileInput.files || []);
if (!files.length) return;
if (useResumable) {
// New resumable batch: reset selectedFiles so the count is correct
window.selectedFiles = [];
_currentResumableIds.clear(); // <--- add this
// Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
for (const f of files) {
resumableInstance.addFile(f);
}
} else {
// If Resumable failed to load, fall back to XHR
processFiles(files);
}
} else {
// Non-resumable: normal XHR path, drag-and-drop etc.
processFiles(files);
}
});
}
function getUserDraftContext() {
const all = loadResumableDraftsAll();
const userKey = getCurrentUserKey();
@@ -253,23 +317,35 @@ function getFilesFromDataTransferItems(items) {
function setDropAreaDefault() {
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) {
dropArea.innerHTML = `
<div id="uploadInstruction" class="upload-instruction">
${t("upload_instruction")}
if (!dropArea) return;
dropArea.innerHTML = `
<div id="uploadInstruction" class="upload-instruction">
${t("upload_instruction")}
</div>
<div id="uploadFileRow" class="upload-file-row">
<button id="customChooseBtn" type="button">${t("choose_files")}</button>
</div>
<div id="fileInfoWrapper" class="file-info-wrapper">
<div id="fileInfoContainer" class="file-info-container">
<span id="fileInfoDefault"> ${t("no_files_selected_default")}</span>
</div>
<div id="uploadFileRow" class="upload-file-row">
<button id="customChooseBtn" type="button">${t("choose_files")}</button>
</div>
<div id="fileInfoWrapper" class="file-info-wrapper">
<div id="fileInfoContainer" class="file-info-container">
<span id="fileInfoDefault"> ${t("no_files_selected_default")}</span>
</div>
</div>
<!-- File input for file picker (files only) -->
<input type="file" id="file" name="file[]" class="form-control-file" multiple style="opacity:0; position:absolute; width:1px; height:1px;" />
`;
}
</div>
<!-- File input for file picker (files only) -->
<input
type="file"
id="file"
name="file[]"
class="form-control-file"
multiple
style="opacity:0; position:absolute; width:1px; height:1px;"
/>
`;
// After rebuilding markup, re-wire controls:
const fileInput = dropArea.querySelector('#file');
wireFileInputChange(fileInput);
wireChooseButton();
}
function adjustFolderHelpExpansion() {
@@ -608,6 +684,7 @@ const useResumable = true;
let resumableInstance = null;
let _pendingPickedFiles = []; // files picked before library/instance ready
let _resumableReady = false;
let _currentResumableIds = new Set();
// Make init async-safe; it resolves when Resumable is constructed
async function initResumableUpload() {
@@ -644,18 +721,20 @@ async function initResumableUpload() {
resumableInstance.opts.query.upload_token = window.csrfToken;
}
const fileInput = document.getElementById("file");
if (fileInput) {
fileInput.addEventListener("change", function () {
for (let i = 0; i < fileInput.files.length; i++) {
resumableInstance.addFile(fileInput.files[i]);
}
});
}
resumableInstance.on("fileAdded", function (file) {
// Build a stable per-file key
const id =
file.uniqueIdentifier ||
((file.fileName || file.name || '') + ':' + (file.size || 0));
// If we've already seen this id in the current batch, skip wiring it again
if (_currentResumableIds.has(id)) {
return;
}
_currentResumableIds.add(id);
// Initialize custom paused flag
file.paused = false;
file.uploadIndex = file.uniqueIdentifier;
@@ -663,13 +742,13 @@ async function initResumableUpload() {
window.selectedFiles = [];
}
window.selectedFiles.push(file);
// Track as in-progress draft at 0%
upsertResumableDraft(file, 0);
showResumableDraftBanner();
const progressContainer = document.getElementById("uploadProgressContainer");
// Check if a wrapper already exists; if not, create one with a UL inside.
let listWrapper = progressContainer.querySelector(".upload-progress-wrapper");
let list;
@@ -685,7 +764,7 @@ async function initResumableUpload() {
} else {
list = listWrapper.querySelector("ul.upload-progress-list");
}
const li = createFileEntry(file);
li.dataset.uploadIndex = file.uniqueIdentifier;
list.appendChild(li);
@@ -1119,9 +1198,17 @@ function submitFiles(allFiles) {
Main initUpload: Sets up file input, drop area, and form submission.
----------------------------------------------------- */
function initUpload() {
const fileInput = document.getElementById("file");
const dropArea = document.getElementById("uploadDropArea");
window.__FR_FLAGS = window.__FR_FLAGS || { wired: {} };
window.__FR_FLAGS.wired = window.__FR_FLAGS.wired || {};
const uploadForm = document.getElementById("uploadFileForm");
const dropArea = document.getElementById("uploadDropArea");
// Always (re)build the inner markup and wire the Choose button
setDropAreaDefault();
wireChooseButton();
const fileInput = document.getElementById("file");
// For file picker, remove directory attributes so only files can be chosen.
if (fileInput) {
@@ -1131,67 +1218,50 @@ function initUpload() {
fileInput.setAttribute("multiple", "");
}
setDropAreaDefault();
// Draganddrop events (for folder uploads) use original processing.
if (dropArea) {
if (dropArea && !dropArea.__uploadBound) {
dropArea.__uploadBound = true;
dropArea.classList.add("upload-drop-area");
dropArea.addEventListener("dragover", function (e) {
e.preventDefault();
dropArea.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#333" : "#f8f8f8";
});
dropArea.addEventListener("dragleave", function (e) {
e.preventDefault();
dropArea.style.backgroundColor = "";
});
dropArea.addEventListener("drop", function (e) {
e.preventDefault();
dropArea.style.backgroundColor = "";
const dt = e.dataTransfer || window.__pendingDropData || null;
window.__pendingDropData = null;
if (dt.items && dt.items.length > 0) {
window.__pendingDropData = null;
if (dt && dt.items && dt.items.length > 0) {
getFilesFromDataTransferItems(dt.items).then(files => {
if (files.length > 0) {
processFiles(files);
}
});
} else if (dt.files && dt.files.length > 0) {
} else if (dt && dt.files && dt.files.length > 0) {
processFiles(dt.files);
}
});
// Clicking drop area triggers file input.
dropArea.addEventListener("click", function () {
if (fileInput) fileInput.click();
});
}
if (fileInput) {
fileInput.addEventListener("change", async function () {
const files = Array.from(fileInput.files || []);
if (!files.length) return;
if (useResumable) {
// New resumable batch: reset selectedFiles so the count is correct
window.selectedFiles = [];
// Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
for (const f of files) {
resumableInstance.addFile(f);
}
} else {
// If Resumable failed to load, fall back to XHR
processFiles(files);
}
} else {
// Non-resumable: normal XHR path, drag-and-drop etc.
processFiles(files);
// Only trigger file picker when clicking the *bare* drop area, not controls inside it
dropArea.addEventListener("click", function (e) {
// If the click originated from the "Choose files" button or the file input itself,
// let their handlers deal with it.
if (e.target.closest('#customChooseBtn') || e.target.closest('#file')) {
return;
}
triggerFilePickerOnce();
});
}
if (uploadForm) {
if (uploadForm && !uploadForm.__uploadSubmitBound) {
uploadForm.__uploadSubmitBound = true;
uploadForm.addEventListener("submit", async function (e) {
e.preventDefault();
@@ -1205,7 +1275,6 @@ function initUpload() {
return;
}
// If we have any files queued in Resumable, treat this as a resumable upload.
const hasResumableFiles =
useResumable &&
resumableInstance &&
@@ -1215,7 +1284,6 @@ function initUpload() {
if (hasResumableFiles) {
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
// Keep folder/token fresh
resumableInstance.opts.query.folder = window.currentFolder || "root";
resumableInstance.opts.query.upload_token = window.csrfToken;
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
@@ -1223,11 +1291,9 @@ function initUpload() {
resumableInstance.upload();
showToast("Resumable upload started...");
} else {
// Hard fallback should basically never happen
submitFiles(files);
}
} else {
// No resumable queue → drag-and-drop / paste / simple input → XHR path
submitFiles(files);
}
});

View File

@@ -1,2 +1,2 @@
// generated by CI
window.APP_VERSION = 'v1.9.13';
window.APP_VERSION = 'v2.2.1';

92
public/js/zoom.js Normal file
View File

@@ -0,0 +1,92 @@
// /js/zoom.js
(function () {
const MIN_PERCENT = 60; // 60%
const MAX_PERCENT = 140; // 140%
const STEP_PERCENT = 5; // 5%
const STORAGE_KEY = 'filerise.appZoomPercent';
function clampPercent(p) {
return Math.max(MIN_PERCENT, Math.min(MAX_PERCENT, p));
}
function updateDisplay(p) {
const el = document.getElementById('zoomDisplay');
if (el) el.textContent = `${p}%`;
}
function applyZoomPercent(p) {
const clamped = clampPercent(p);
const scale = clamped / 100;
document.documentElement.style.setProperty('--app-zoom', String(scale));
try { localStorage.setItem(STORAGE_KEY, String(clamped)); } catch {}
updateDisplay(clamped);
return clamped;
}
function getCurrentPercent() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const n = parseInt(raw, 10);
if (Number.isFinite(n) && n > 0) return clampPercent(n);
}
} catch {}
const v = getComputedStyle(document.documentElement)
.getPropertyValue('--app-zoom')
.trim();
const n = parseFloat(v);
if (Number.isFinite(n) && n > 0) {
return clampPercent(Math.round(n * 100));
}
return 100;
}
// Public-ish API (percent-based)
window.fileriseZoom = {
in() {
const next = getCurrentPercent() + STEP_PERCENT;
return applyZoomPercent(next);
},
out() {
const next = getCurrentPercent() - STEP_PERCENT;
return applyZoomPercent(next);
},
reset() {
return applyZoomPercent(100);
},
setPercent(p) {
return applyZoomPercent(p);
},
currentPercent: getCurrentPercent
};
function initZoomUI() {
// bind buttons
const btns = document.querySelectorAll('.zoom-btn[data-zoom]');
btns.forEach(btn => {
if (btn.__zoomBound) return;
btn.__zoomBound = true;
btn.addEventListener('click', () => {
const mode = btn.dataset.zoom;
if (mode === 'in') window.fileriseZoom.in();
else if (mode === 'out') window.fileriseZoom.out();
else if (mode === 'reset') window.fileriseZoom.reset();
});
});
// apply initial zoom + update display
const initial = getCurrentPercent();
applyZoomPercent(initial);
}
// Run immediately if DOM is ready, otherwise wait
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initZoomUI, { once: true });
} else {
initZoomUI();
}
})();

146
public/portal-login.html Normal file
View 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;">
Youll 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
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 KiB

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

BIN
resources/portal-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

View File

@@ -1,19 +1,25 @@
#!/usr/bin/env bash
# === Update FileRise to v1.9.1 (safe rsync) ===
# shellcheck disable=SC2155 # we intentionally assign 'stamp' with command substitution
# === Update FileRise to v2.1.0 (safe rsync, no composer on demo) ===
set -Eeuo pipefail
VER="v1.9.1"
ASSET="FileRise-${VER}.zip" # If the asset name is different, set it exactly (e.g. FileRise-v1.9.0.zip)
VER="v2.1.0"
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
WEBROOT="/var/www"
TMP="/tmp/filerise-update"
# 0) (optional) quick backup of critical bits
# 0) quick backup of critical bits (include Pro/demo stuff too)
stamp="$(date +%F-%H%M)"
mkdir -p /root/backups
tar -C "$WEBROOT" -czf "/root/backups/filerise-$stamp.tgz" \
public/.htaccess config users uploads metadata || true
public/.htaccess \
config \
users \
uploads \
metadata \
filerise-bundles \
filerise-config \
filerise-site || true
echo "Backup saved to /root/backups/filerise-$stamp.tgz"
# 1) Fetch the release zip
@@ -29,12 +35,17 @@ STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" |
# 3) Sync code into /var/www
# - keep public/.htaccess
# - keep data dirs and current config.php
# - DO NOT touch filerise-site / bundles / demo config
# - DO NOT touch vendor/ so Stripe + other libs stay intact on demo
rsync -a --delete \
--exclude='public/.htaccess' \
--exclude='uploads/***' \
--exclude='users/***' \
--exclude='metadata/***' \
--exclude='config/config.php' \
--exclude='filerise-bundles/***' \
--exclude='filerise-config/***' \
--exclude='filerise-site/***' \
--exclude='vendor/***' \
--exclude='.github/***' \
--exclude='docker-compose.yml' \
"$STAGE_DIR"/ "$WEBROOT"/
@@ -42,13 +53,20 @@ rsync -a --delete \
# 4) Ownership (Ubuntu/Debian w/ Apache)
chown -R www-data:www-data "$WEBROOT"
# 5) (optional) Composer autoload optimization if composer is available
if command -v composer >/dev/null 2>&1; then
cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
composer install --no-dev --optimize-autoloader
# 5) Composer — still disabled on demo
# if command -v composer >/dev/null 2>&1; then
# cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
# composer install --no-dev --optimize-autoloader
# fi
# 6) Force demo mode ON in config/config.php
CFG_FILE="$WEBROOT/config/config.php"
if [[ -f "$CFG_FILE" ]]; then
cp "$CFG_FILE" "${CFG_FILE}.bak.$stamp" || true
sed -i "s/define('FR_DEMO_MODE',[[:space:]]*false);/define('FR_DEMO_MODE', true);/" "$CFG_FILE" || true
fi
# 6) Reload Apache (dont fail the whole script if reload isnt available)
# 7) Reload Apache (dont fail the whole script if reload isnt available)
systemctl reload apache2 2>/dev/null || true
echo "FileRise updated to ${VER} (code). Data and public/.htaccess preserved."
echo "FileRise updated to ${VER} (code). Demo mode forced ON. Data, Pro bundles, site, and vendor/ (Stripe) preserved."

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
// src/cli/disk_usage_scan.php
//
// Build or refresh the disk usage snapshot used by the Admin "Storage / Disk Usage" view.
require __DIR__ . '/../../config/config.php';
require __DIR__ . '/../../src/models/DiskUsageModel.php';
$start = microtime(true);
try {
$snapshot = DiskUsageModel::buildSnapshot();
$elapsed = microtime(true) - $start;
$bytes = (int)($snapshot['root_bytes'] ?? 0);
$files = (int)($snapshot['root_files'] ?? 0);
$human = function (int $b): string {
if ($b <= 0) return '0 B';
$units = ['B','KB','MB','GB','TB','PB'];
$i = (int)floor(log($b, 1024));
$i = max(0, min($i, count($units) - 1));
$val = $b / pow(1024, $i);
return sprintf('%.2f %s', $val, $units[$i]);
};
$msg = sprintf(
"Disk usage snapshot written to %s\nScanned %d files, total %s in %.2f seconds.\n",
DiskUsageModel::snapshotPath(),
$files,
$human($bytes),
$elapsed
);
fwrite(STDOUT, $msg);
exit(0);
} catch (Throwable $e) {
fwrite(STDERR, "Error building disk usage snapshot: " . $e->getMessage() . "\n");
exit(1);
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
// src/controllers/AdminController.php
require_once __DIR__ . '/../../config/config.php';
@@ -176,6 +177,7 @@ class AdminController
'version' => $proVersion,
'license' => $licenseString,
],
'demoMode' => defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false,
];
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
@@ -240,7 +242,7 @@ public function setLicense(): void
// Store license + updatedAt in JSON file
if (!defined('PRO_LICENSE_FILE')) {
// Fallback if constant not defined for some reason
define('PRO_LICENSE_FILE', PROJECT_ROOT . '/users/proLicense.json');
define('PRO_LICENSE_FILE', rtrim(USERS_DIR, "/\\") . '/proLicense.json');
}
$payload = [
@@ -272,6 +274,126 @@ 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) {
@@ -445,10 +567,11 @@ public function installProBundle(): void
$projectRoot = rtrim(PROJECT_ROOT, DIRECTORY_SEPARATOR);
// Where Pro bundle code lives (defaults to PROJECT_ROOT . '/users/pro')
// Where Pro bundle code lives (defaults to USERS_DIR . '/pro')
$projectRoot = rtrim(PROJECT_ROOT, DIRECTORY_SEPARATOR);
$bundleRoot = defined('FR_PRO_BUNDLE_DIR')
? rtrim(FR_PRO_BUNDLE_DIR, DIRECTORY_SEPARATOR)
: ($projectRoot . DIRECTORY_SEPARATOR . 'users' . DIRECTORY_SEPARATOR . 'pro');
: (rtrim(USERS_DIR, "/\\") . DIRECTORY_SEPARATOR . 'pro');
// Put README-Pro.txt / LICENSE-Pro.txt inside the bundle dir as well
$proDocsDir = $bundleRoot;

View 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,
];
}
}

View File

@@ -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"] ?? "");
@@ -318,6 +327,14 @@ class UserController
exit;
}
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $username === 'demo') {
http_response_code(403);
echo json_encode([
'error' => 'TOTP settings are disabled for the demo account.'
]);
exit;
}
$totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false;
$result = UserModel::updateUserPanel($username, $totp_enabled);
echo json_encode($result);
@@ -339,6 +356,14 @@ class UserController
exit;
}
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $username === 'demo') {
http_response_code(403);
echo json_encode([
'error' => 'TOTP settings are disabled for the demo account.'
]);
exit;
}
$result = UserModel::disableTOTPSecret($username);
if ($result) {
echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
@@ -403,6 +428,16 @@ class UserController
}
$userId = $_SESSION['username'];
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $userId === 'demo') {
http_response_code(403);
echo json_encode([
'status' => 'error',
'message' => 'TOTP settings are disabled for the demo account.',
]);
exit;
}
if (!preg_match(REGEX_USER, $userId)) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
@@ -429,6 +464,14 @@ class UserController
exit;
}
$username = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? '');
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $username === 'demo') {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'TOTP setup is disabled for the demo account.']);
}
self::requireCsrf();
// Fix: if username not present (pending flow), fall back to pending_login_user
@@ -608,6 +651,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']);

View File

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

View File

@@ -0,0 +1,723 @@
<?php
// src/models/DiskUsageModel.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/FS.php';
/**
* DiskUsageModel
*
* Builds and reads a cached snapshot of disk usage under UPLOAD_DIR.
* Snapshot is stored as JSON under META_DIR . '/disk_usage.json'.
*
* Folder keys mirror the rest of FileRise:
* - "root" is the upload root
* - "foo/bar" are subfolders under UPLOAD_DIR
*
* We intentionally skip:
* - trash subtree
* - profile_pics subtree
* - dot-prefixed names
* - FS::IGNORE() entries like @eaDir, .DS_Store, etc.
*/
class DiskUsageModel
{
/** Where we persist the snapshot JSON. */
public const SNAPSHOT_BASENAME = 'disk_usage.json';
/** Maximum number of per-file records to keep (for Top N view). */
private const TOP_FILE_LIMIT = 1000;
/**
* Absolute path to the snapshot JSON file.
*/
public static function snapshotPath(): string
{
$meta = rtrim((string)META_DIR, '/\\');
return $meta . DIRECTORY_SEPARATOR . self::SNAPSHOT_BASENAME;
}
/**
* Build a fresh snapshot of disk usage under UPLOAD_DIR and write it to disk.
*
* Returns the structured snapshot array (same shape as stored JSON).
*
* @throws RuntimeException on configuration or IO errors.
*/
public static function buildSnapshot(): array
{
$start = microtime(true);
$root = realpath(UPLOAD_DIR);
if ($root === false || !is_dir($root)) {
throw new RuntimeException('Uploads directory is not configured correctly.');
}
$root = rtrim($root, DIRECTORY_SEPARATOR);
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP();
// Folder map: key => [
// 'key' => string,
// 'parent' => string|null,
// 'name' => string,
// 'bytes' => int,
// 'files' => int,
// 'dirs' => int,
// 'latest_mtime' => int
// ]
$folders = [];
// Root entry
$folders['root'] = [
'key' => 'root',
'parent' => null,
'name' => 'root',
'bytes' => 0,
'files' => 0,
'dirs' => 0,
'latest_mtime' => 0,
];
// File records (we may trim to TOP_FILE_LIMIT later)
// Each item: [
// 'folder' => folderKey,
// 'name' => file name,
// 'path' => "folder/name" or just name if root,
// 'bytes' => int,
// 'mtime' => int
// ]
$files = [];
$rootLen = strlen($root);
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$root,
FilesystemIterator::SKIP_DOTS
| FilesystemIterator::FOLLOW_SYMLINKS
),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($it as $path => $info) {
/** @var SplFileInfo $info */
$name = $info->getFilename();
// Skip dotfiles / dotdirs
if ($name === '.' || $name === '..') {
continue;
}
if ($name[0] === '.') {
continue;
}
// Skip system/ignored entries
if (in_array($name, $IGNORE, true)) {
continue;
}
// Relative path under UPLOAD_DIR, normalized with '/'
$rel = substr($path, $rootLen);
$rel = str_replace('\\', '/', $rel);
$rel = ltrim($rel, '/');
// Should only happen for the root itself, which we seeded
if ($rel === '') {
continue;
}
$isDir = $info->isDir();
if ($isDir) {
$folderKey = $rel;
$lowerRel = strtolower($folderKey);
// Skip trash/profile_pics subtrees entirely
if ($lowerRel === 'trash' || strpos($lowerRel, 'trash/') === 0) {
$it->next();
continue;
}
if ($lowerRel === 'profile_pics' || strpos($lowerRel, 'profile_pics/') === 0) {
$it->next();
continue;
}
// Skip SKIP entries at any level
$baseLower = strtolower(basename($folderKey));
if (in_array($baseLower, $SKIP, true)) {
$it->next();
continue;
}
// Register folder
if (!isset($folders[$folderKey])) {
$parent = self::parentKeyOf($folderKey);
if (!isset($folders[$parent])) {
// Ensure parent exists (important for aggregation step later)
$folders[$parent] = [
'key' => $parent,
'parent' => self::parentKeyOf($parent),
'name' => self::basenameKey($parent),
'bytes' => 0,
'files' => 0,
'dirs' => 0,
'latest_mtime' => 0,
];
}
$folders[$folderKey] = [
'key' => $folderKey,
'parent' => $parent,
'name' => self::basenameKey($folderKey),
'bytes' => 0,
'files' => 0,
'dirs' => 0,
'latest_mtime' => 0,
];
// Increment dir count on parent
if ($parent !== null && isset($folders[$parent])) {
$folders[$parent]['dirs']++;
}
}
continue;
}
// File entry
// Determine folder key where this file resides
$relDir = str_replace('\\', '/', dirname($rel));
if ($relDir === '.' || $relDir === '') {
$folderKey = 'root';
} else {
$folderKey = $relDir;
}
$lowerFolder = strtolower($folderKey);
if ($lowerFolder === 'trash' || strpos($lowerFolder, 'trash/') === 0) {
continue;
}
if ($lowerFolder === 'profile_pics' || strpos($lowerFolder, 'profile_pics/') === 0) {
continue;
}
// Skip SKIP entries for files inside unwanted app-specific dirs
$baseLower = strtolower(basename($folderKey));
if (in_array($baseLower, $SKIP, true)) {
continue;
}
// Ensure folder exists in map
if (!isset($folders[$folderKey])) {
$parent = self::parentKeyOf($folderKey);
if (!isset($folders[$parent])) {
$folders[$parent] = [
'key' => $parent,
'parent' => self::parentKeyOf($parent),
'name' => self::basenameKey($parent),
'bytes' => 0,
'files' => 0,
'dirs' => 0,
'latest_mtime' => 0,
];
}
$folders[$folderKey] = [
'key' => $folderKey,
'parent' => $parent,
'name' => self::basenameKey($folderKey),
'bytes' => 0,
'files' => 0,
'dirs' => 0,
'latest_mtime' => 0,
];
if ($parent !== null && isset($folders[$parent])) {
$folders[$parent]['dirs']++;
}
}
$bytes = (int)$info->getSize();
$mtime = (int)$info->getMTime();
// Update folder leaf stats
$folders[$folderKey]['bytes'] += $bytes;
$folders[$folderKey]['files']++;
if ($mtime > $folders[$folderKey]['latest_mtime']) {
$folders[$folderKey]['latest_mtime'] = $mtime;
}
// Remember file record (we may trim later)
$filePath = ($folderKey === 'root')
? $name
: ($folderKey . '/' . $name);
$files[] = [
'folder' => $folderKey,
'name' => $name,
'path' => $filePath,
'bytes' => $bytes,
'mtime' => $mtime,
];
}
// Aggregate folder bytes up the tree so each folder includes its descendants.
// Process folders from deepest to shallowest.
$keys = array_keys($folders);
usort($keys, function (string $a, string $b): int {
return self::depthOf($b) <=> self::depthOf($a);
});
foreach ($keys as $key) {
$parent = $folders[$key]['parent'];
if ($parent !== null && isset($folders[$parent])) {
$folders[$parent]['bytes'] += $folders[$key]['bytes'];
$folders[$parent]['files'] += $folders[$key]['files'];
$folders[$parent]['dirs'] += $folders[$key]['dirs'];
$parentLatest = $folders[$parent]['latest_mtime'];
if ($folders[$key]['latest_mtime'] > $parentLatest) {
$folders[$parent]['latest_mtime'] = $folders[$key]['latest_mtime'];
}
}
}
// Root aggregate
$rootBytes = isset($folders['root']) ? (int)$folders['root']['bytes'] : 0;
$rootFiles = isset($folders['root']) ? (int)$folders['root']['files'] : 0;
// Count of folders under the upload root (excluding "root" itself)
$rootFolders = 0;
if (!empty($folders)) {
$rootFolders = max(0, count($folders) - 1);
}
// Trim top files list
usort($files, function (array $a, array $b): int {
// descending by bytes, then by path
if ($a['bytes'] === $b['bytes']) {
return strcmp($a['path'], $b['path']);
}
return ($a['bytes'] < $b['bytes']) ? 1 : -1;
});
if (count($files) > self::TOP_FILE_LIMIT) {
$files = array_slice($files, 0, self::TOP_FILE_LIMIT);
}
$snapshot = [
'version' => 1,
'generated_at' => time(),
'scan_seconds' => microtime(true) - $start,
'root_bytes' => $rootBytes,
'root_files' => $rootFiles,
'root_folders' => $rootFolders,
// Store folders as numerically-indexed array
'folders' => array_values($folders),
'files' => $files,
];
$path = self::snapshotPath();
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Failed to encode disk usage snapshot.');
}
if (@file_put_contents($path, $json) === false) {
throw new RuntimeException('Failed to write disk usage snapshot to ' . $path);
}
return $snapshot;
}
/**
* Load the snapshot from disk, or return null if missing or invalid.
*/
public static function loadSnapshot(): ?array
{
$path = self::snapshotPath();
if (!is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false || $raw === '') {
return null;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return null;
}
if (!isset($data['version']) || (int)$data['version'] !== 1) {
return null;
}
return $data;
}
/**
* Compute a lightweight summary for the Admin panel.
*
* @param int $maxTopFolders How many top folders to include.
* @param int $maxTopFilesPreview Optional number of top files to include as preview.
* @return array
*/
public static function getSummary(int $maxTopFolders = 5, int $maxTopFilesPreview = 0): array
{
$snapshot = self::loadSnapshot();
if ($snapshot === null) {
return [
'ok' => false,
'error' => 'no_snapshot',
'message' => 'No disk usage snapshot found. Run the disk usage scan to generate one.',
'generatedAt' => null,
];
}
$rootBytes = (int)($snapshot['root_bytes'] ?? 0);
$folders = is_array($snapshot['folders'] ?? null) ? $snapshot['folders'] : [];
// --- Build "volumes" across core FileRise dirs (UPLOAD/USERS/META) ---
$volumeRoots = [
'uploads' => defined('UPLOAD_DIR') ? (string)UPLOAD_DIR : null,
'users' => defined('USERS_DIR') ? (string)USERS_DIR : null,
'meta' => defined('META_DIR') ? (string)META_DIR : null,
];
$volumesMap = [];
$uploadReal = null;
if (defined('UPLOAD_DIR')) {
$tmp = realpath(UPLOAD_DIR);
if ($tmp !== false && is_dir($tmp)) {
$uploadReal = $tmp;
}
}
foreach ($volumeRoots as $kind => $dir) {
if ($dir === null || $dir === '') {
continue;
}
$real = realpath($dir);
if ($real === false || !is_dir($real)) {
continue;
}
$total = @disk_total_space($real);
$free = @disk_free_space($real);
if ($total === false || $free === false || $total <= 0) {
continue;
}
$total = (int)$total;
$free = (int)$free;
$used = $total - $free;
if ($used < 0) {
$used = 0;
}
$usedPct = ($used * 100.0) / $total;
// Group by same total+free => assume same underlying volume
$bucketKey = $total . ':' . $free;
if (!isset($volumesMap[$bucketKey])) {
$volumesMap[$bucketKey] = [
'totalBytes' => $total,
'freeBytes' => $free,
'usedBytes' => $used,
'usedPercent' => $usedPct,
'roots' => [],
];
}
$volumesMap[$bucketKey]['roots'][] = [
'kind' => $kind, // "uploads" | "users" | "meta"
'path' => $real,
];
}
$volumes = array_values($volumesMap);
// Sort by usedPercent desc (heaviest first)
usort($volumes, function (array $a, array $b): int {
$pa = (float)($a['usedPercent'] ?? 0.0);
$pb = (float)($b['usedPercent'] ?? 0.0);
if ($pa === $pb) {
return 0;
}
return ($pa < $pb) ? 1 : -1;
});
// Backwards-compat: root filesystem metrics based on the volume
// that contains UPLOAD_DIR (if we can detect it).
$fsTotalBytes = null;
$fsFreeBytes = null;
$fsUsedBytes = null;
$fsUsedPct = null;
if ($uploadReal && !empty($volumes)) {
foreach ($volumes as $vol) {
foreach ($vol['roots'] as $root) {
if (!isset($root['path'])) continue;
if ((string)$root['path'] === (string)$uploadReal) {
$fsTotalBytes = (int)$vol['totalBytes'];
$fsFreeBytes = (int)$vol['freeBytes'];
$fsUsedBytes = (int)$vol['usedBytes'];
$fsUsedPct = (float)$vol['usedPercent'];
break 2;
}
}
}
}
// Top N non-root folders by bytes (from snapshot)
$candidates = array_filter($folders, function (array $f): bool {
return isset($f['key']) && $f['key'] !== 'root';
});
usort($candidates, function (array $a, array $b): int {
$ba = (int)($a['bytes'] ?? 0);
$bb = (int)($b['bytes'] ?? 0);
if ($ba === $bb) {
return strcmp((string)$a['key'], (string)$b['key']);
}
return ($ba < $bb) ? 1 : -1;
});
if ($maxTopFolders > 0 && count($candidates) > $maxTopFolders) {
$candidates = array_slice($candidates, 0, $maxTopFolders);
}
$topFolders = [];
foreach ($candidates as $f) {
$bytes = (int)($f['bytes'] ?? 0);
$pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0;
$topFolders[] = [
'folder' => (string)$f['key'],
'name' => (string)$f['name'],
'bytes' => $bytes,
'files' => (int)($f['files'] ?? 0),
'dirs' => (int)($f['dirs'] ?? 0),
'latest_mtime' => (int)($f['latest_mtime'] ?? 0),
'percentOfTotal' => $pct,
];
}
// totalFolders: prefer snapshot["root_folders"], but fall back to counting
$totalFolders = isset($snapshot['root_folders'])
? (int)$snapshot['root_folders']
: max(0, count($folders) - 1);
$out = [
'ok' => true,
'generatedAt' => (int)($snapshot['generated_at'] ?? 0),
'scanSeconds' => (float)($snapshot['scan_seconds'] ?? 0.0),
'totalBytes' => $rootBytes,
'totalFiles' => (int)($snapshot['root_files'] ?? 0),
'totalFolders' => $totalFolders,
'topFolders' => $topFolders,
// original fields (for single-root view)
'uploadRoot' => $uploadReal,
'fsTotalBytes' => $fsTotalBytes,
'fsFreeBytes' => $fsFreeBytes,
'fsUsedBytes' => $fsUsedBytes,
'fsUsedPercent' => $fsUsedPct,
// new grouped volumes: each with total/free/used and roots[]
'volumes' => $volumes,
];
if ($maxTopFilesPreview > 0) {
$files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : [];
if (count($files) > $maxTopFilesPreview) {
$files = array_slice($files, 0, $maxTopFilesPreview);
}
$out['topFiles'] = $files;
}
return $out;
}
/**
* Return direct children (folders + files) of a given folder key.
*
* @param string $folderKey
* @return array
*/
public static function getChildren(string $folderKey): array
{
$folderKey = ($folderKey === '' || $folderKey === '/') ? 'root' : $folderKey;
$snapshot = self::loadSnapshot();
if ($snapshot === null) {
return [
'ok' => false,
'error' => 'no_snapshot',
];
}
$rootBytes = (int)($snapshot['root_bytes'] ?? 0);
$folders = is_array($snapshot['folders'] ?? null) ? $snapshot['folders'] : [];
$files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : [];
// Index folders by key
$folderByKey = [];
foreach ($folders as $f) {
if (!isset($f['key'])) continue;
$folderByKey[(string)$f['key']] = $f;
}
if (!isset($folderByKey[$folderKey])) {
return [
'ok' => false,
'error' => 'folder_not_found',
];
}
$childrenFolders = [];
foreach ($folders as $f) {
if (!isset($f['parent']) || !isset($f['key'])) continue;
if ((string)$f['parent'] === $folderKey) {
$bytes = (int)($f['bytes'] ?? 0);
$pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0;
$childrenFolders[] = [
'type' => 'folder',
'folder' => (string)$f['key'],
'name' => (string)$f['name'],
'bytes' => $bytes,
'files' => (int)($f['files'] ?? 0),
'dirs' => (int)($f['dirs'] ?? 0),
'latest_mtime' => (int)($f['latest_mtime'] ?? 0),
'percentOfTotal' => $pct,
];
}
}
$childrenFiles = [];
foreach ($files as $file) {
if (!isset($file['folder']) || !isset($file['name'])) continue;
if ((string)$file['folder'] !== $folderKey) continue;
$bytes = (int)($file['bytes'] ?? 0);
$pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0;
$childrenFiles[] = [
'type' => 'file',
'folder' => (string)$file['folder'],
'name' => (string)$file['name'],
'path' => (string)($file['path'] ?? $file['name']),
'bytes' => $bytes,
'mtime' => (int)($file['mtime'] ?? 0),
'percentOfTotal' => $pct,
];
}
// Sort children: folders first (by bytes desc), then files (by bytes desc)
usort($childrenFolders, function (array $a, array $b): int {
$ba = (int)($a['bytes'] ?? 0);
$bb = (int)($b['bytes'] ?? 0);
if ($ba === $bb) {
return strcmp((string)$a['name'], (string)$b['name']);
}
return ($ba < $bb) ? 1 : -1;
});
usort($childrenFiles, function (array $a, array $b): int {
$ba = (int)($a['bytes'] ?? 0);
$bb = (int)($b['bytes'] ?? 0);
if ($ba === $bb) {
return strcmp((string)$a['name'], (string)$b['name']);
}
return ($ba < $bb) ? 1 : -1;
});
return [
'ok' => true,
'folder' => $folderKey,
'folders' => $childrenFolders,
'files' => $childrenFiles,
];
}
/**
* Return the global Top N files by size from the snapshot.
*
* @param int $limit
* @return array
*/
public static function getTopFiles(int $limit = 100): array
{
$snapshot = self::loadSnapshot();
if ($snapshot === null) {
return [
'ok' => false,
'error' => 'no_snapshot',
];
}
$rootBytes = (int)($snapshot['root_bytes'] ?? 0);
$files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : [];
if ($limit > 0 && count($files) > $limit) {
$files = array_slice($files, 0, $limit);
}
$out = [];
foreach ($files as $file) {
$bytes = (int)($file['bytes'] ?? 0);
$pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0;
$out[] = [
'folder' => (string)($file['folder'] ?? 'root'),
'name' => (string)($file['name'] ?? ''),
'path' => (string)($file['path'] ?? ($file['name'] ?? '')),
'bytes' => $bytes,
'mtime' => (int)($file['mtime'] ?? 0),
'percentOfTotal' => $pct,
];
}
return [
'ok' => true,
'files' => $out,
];
}
/**
* Helper: derive the parent folder key ("root" -> null, "foo/bar" -> "foo").
*/
private static function parentKeyOf(string $key): ?string
{
if ($key === 'root' || $key === '') {
return null;
}
$key = trim($key, '/');
if ($key === '') return null;
$pos = strrpos($key, '/');
if ($pos === false) {
return 'root';
}
$parent = substr($key, 0, $pos);
return ($parent === '' ? 'root' : $parent);
}
/**
* Helper: basename of a folder key. "root" -> "root", "foo/bar" -> "bar".
*/
private static function basenameKey(?string $key): string
{
if ($key === null || $key === '' || $key === 'root') {
return 'root';
}
$key = trim($key, '/');
$pos = strrpos($key, '/');
if ($pos === false) {
return $key;
}
return substr($key, $pos + 1);
}
/**
* Helper: approximate depth of a folder key (root->0, "foo"->1, "foo/bar"->2, etc.)
*/
private static function depthOf(string $key): int
{
if ($key === '' || $key === 'root') return 0;
return substr_count(trim($key, '/'), '/') + 1;
}
}

View File

@@ -549,6 +549,67 @@ class FileModel {
];
}
public static function deleteFilesPermanent(string $folder, array $files): array
{
$errors = [];
$deleted = [];
list($uploadDir, $err) = self::resolveFolderPath($folder, false);
if ($err) return ['error' => $err];
$uploadDir = rtrim($uploadDir, '/\\') . DIRECTORY_SEPARATOR;
$safeFileNamePattern = REGEX_FILE_NAME;
foreach ($files as $fileName) {
$originalName = basename(trim((string)$fileName));
$basename = $originalName;
if ($basename === '') {
$errors[] = 'Empty file name.';
continue;
}
if (!preg_match($safeFileNamePattern, $basename)) {
$errors[] = "$basename has an invalid name.";
continue;
}
$filePath = $uploadDir . $basename;
if (file_exists($filePath)) {
if (!@unlink($filePath)) {
$errors[] = "Failed to delete {$basename}.";
continue;
}
}
$deleted[] = $basename;
// Remove from folder metadata if present
$metadataFile = self::getMetadataFilePath($folder);
if (file_exists($metadataFile)) {
$meta = json_decode(file_get_contents($metadataFile), true);
if (is_array($meta) && isset($meta[$basename])) {
unset($meta[$basename]);
@file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX);
}
}
}
if ($errors && !$deleted) {
return ['error' => implode('; ', $errors)];
}
if ($errors) {
return [
'error' => implode('; ', $errors),
'success' => 'Deleted: ' . implode(', ', $deleted),
];
}
return ['success' => 'Deleted: ' . implode(', ', $deleted)];
}
/**
* Creates a ZIP archive of the specified files from a given folder.
*

View File

@@ -11,87 +11,111 @@ class FolderModel
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
* ============================================================ */
public static function countVisible(string $folder, string $user, array $perms): array
{
$folder = ACL::normalizeFolder($folder);
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
$canViewFolder = ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $folder)
|| ACL::canReadOwn($user, $perms, $folder);
if (!$canViewFolder) return ['folders' => 0, 'files' => 0];
$base = realpath((string)UPLOAD_DIR);
if ($base === false) return ['folders' => 0, 'files' => 0];
// Resolve target dir + ACL-relative prefix
if ($folder === 'root') {
$dir = $base;
$relPrefix = '';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!self::isSafeSegment($seg)) return ['folders' => 0, 'files' => 0];
}
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dir = self::safeReal($base, $guess);
if ($dir === null || !is_dir($dir)) return ['folders' => 0, 'files' => 0];
$relPrefix = implode('/', $parts);
}
// Ignore lists (expandable)
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics'];
$entries = @scandir($dir);
if ($entries === false) return ['folders' => 0, 'files' => 0];
$hasChildFolder = false;
$hasFile = false;
// Cap scanning to avoid pathological dirs
$MAX_SCAN = 4000;
$scanned = 0;
foreach ($entries as $name) {
if (++$scanned > $MAX_SCAN) break;
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue;
$abs = $dir . DIRECTORY_SEPARATOR . $name;
if (@is_dir($abs)) {
// Symlink defense on children
if (@is_link($abs)) {
$safe = self::safeReal($base, $abs);
if ($safe === null || !is_dir($safe)) continue;
}
// Only count child dirs the user can view (admin/read/read_own)
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
if (
ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $childRel)
|| ACL::canReadOwn($user, $perms, $childRel)
) {
$hasChildFolder = true;
}
} elseif (@is_file($abs)) {
// Any file present is enough for the "files" flag once the folder itself is viewable
$hasFile = true;
}
if ($hasChildFolder && $hasFile) break; // early exit
}
return [
'folders' => $hasChildFolder ? 1 : 0,
'files' => $hasFile ? 1 : 0,
];
}
public static function countVisible(string $folder, string $user, array $perms): array
{
$folder = ACL::normalizeFolder($folder);
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
$canViewFolder = ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $folder)
|| ACL::canReadOwn($user, $perms, $folder);
if (!$canViewFolder) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
// NEW: distinguish full read vs own-only for this folder
$hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder);
// if !$hasFullRead but $canViewFolder is true, theyre effectively "view own" only
$base = realpath((string)UPLOAD_DIR);
if ($base === false) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
// Resolve target dir + ACL-relative prefix
if ($folder === 'root') {
$dir = $base;
$relPrefix = '';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!self::isSafeSegment($seg)) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
}
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dir = self::safeReal($base, $guess);
if ($dir === null || !is_dir($dir)) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
$relPrefix = implode('/', $parts);
}
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics'];
$entries = @scandir($dir);
if ($entries === false) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
$folderCount = 0;
$fileCount = 0;
$totalBytes = 0;
$MAX_SCAN = 4000;
$scanned = 0;
foreach ($entries as $name) {
if (++$scanned > $MAX_SCAN) {
break;
}
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue;
$abs = $dir . DIRECTORY_SEPARATOR . $name;
if (@is_dir($abs)) {
if (@is_link($abs)) {
$safe = self::safeReal($base, $abs);
if ($safe === null || !is_dir($safe)) {
continue;
}
}
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
if (
ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $childRel)
|| ACL::canReadOwn($user, $perms, $childRel)
) {
$folderCount++;
}
} elseif (@is_file($abs)) {
// Only count files if the user has full read on *this* folder.
// If theyre view_own-only here, dont leak or mis-report counts.
if (!$hasFullRead) {
continue;
}
$fileCount++;
$sz = @filesize($abs);
if (is_int($sz) && $sz > 0) {
$totalBytes += $sz;
}
}
}
return [
'folders' => $folderCount,
'files' => $fileCount,
'bytes' => $totalBytes,
];
}
/* Helpers (private) */
private static function isSafeSegment(string $name): bool
@@ -459,6 +483,64 @@ class FolderModel
}
public static function deleteFolderRecursiveAdmin(string $folder): array
{
if (strtolower($folder) === 'root') {
return ['error' => 'Cannot delete root folder.'];
}
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
if ($err) return ['error' => $err];
if (!is_dir($real)) {
return ['error' => 'Folder not found.'];
}
$errors = [];
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $path => $info) {
if ($info->isDir()) {
if (!@rmdir($path)) {
$errors[] = "Failed to delete directory: {$path}";
}
} else {
if (!@unlink($path)) {
$errors[] = "Failed to delete file: {$path}";
}
}
}
if (!@rmdir($real)) {
$errors[] = "Failed to delete directory: {$real}";
}
// Remove metadata JSONs for this subtree
$relative = trim($relative, "/\\ ");
if ($relative !== '' && $relative !== 'root') {
$prefix = str_replace(['/', '\\', ' '], '-', $relative);
$globPat = META_DIR . $prefix . '*_metadata.json';
$metaFiles = glob($globPat) ?: [];
foreach ($metaFiles as $mf) {
@unlink($mf);
}
}
// Remove ownership mappings for the subtree.
self::removeOwnerForTree($relative);
if ($errors) {
return ['error' => implode('; ', $errors)];
}
return ['success' => 'Folder and all contents deleted.'];
}
/**
* Deletes a folder if it is empty and removes its corresponding metadata.
* Also removes ownership mappings for this folder and all its descendants.