Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a5f5fcfd9 | ||
|
|
a4efa4ff45 | ||
|
|
acac4235ad | ||
|
|
35099a5fe1 | ||
|
|
bb0ac9f421 | ||
|
|
b06c44a5ba | ||
|
|
e58751dd83 | ||
|
|
6d4881b068 | ||
|
|
62aacd53c4 | ||
|
|
39e69882e5 | ||
|
|
909baed16c | ||
|
|
c61bbf67f8 | ||
|
|
d1ee6f11fb | ||
|
|
b417217552 | ||
|
|
e2d1b705bd | ||
|
|
4798afa89e | ||
|
|
da968e51e1 | ||
|
|
c06452600d | ||
|
|
758ad7719b | ||
|
|
3587f5041c | ||
|
|
da14d204a6 | ||
|
|
2a87002e1f | ||
|
|
4b83facc97 | ||
|
|
3e473d57b4 | ||
|
|
f2ce43f18f | ||
|
|
a50fa30db2 | ||
|
|
d6631adc2d | ||
|
|
997e5067d3 | ||
|
|
1c0ac50048 | ||
|
|
8fc716387b | ||
|
|
fe3a58924b | ||
|
|
47b4cc4489 | ||
|
|
3f0d1780a1 | ||
|
|
3b62e27c7c | ||
|
|
f967134631 | ||
|
|
6b93d65d6a | ||
|
|
1856325b1f | ||
|
|
9e6da52691 | ||
|
|
959206c91c | ||
|
|
837deddec5 | ||
|
|
2810b97568 | ||
|
|
175c5f962f | ||
|
|
827e65e367 | ||
|
|
fd8029a6bf | ||
|
|
de79395c3d | ||
|
|
aa6f40bc24 | ||
|
|
abc105e087 | ||
|
|
d3bcac4db0 | ||
|
|
0b065111b0 | ||
|
|
3589a1c232 | ||
|
|
1b4a93b060 | ||
|
|
bf077b142b | ||
|
|
f78e2f3f16 | ||
|
|
08a84419f0 | ||
|
|
49d3588322 | ||
|
|
e1b20a9f1d | ||
|
|
0ec8103fbf |
486
CHANGELOG.md
@@ -1,6 +1,490 @@
|
||||
# Changelog
|
||||
|
||||
## changes 11/18/2025 (v1.9.10)
|
||||
## Changese 12/6/2025 (v2.3.5)
|
||||
|
||||
release(v2.3.5): make client portals ACL-aware and improve admin UX
|
||||
|
||||
- Wire PortalController into ACL.php and expose canUpload/canDownload flags
|
||||
- Gate portal uploads/downloads on both portal flags and folder ACL for logged-in users
|
||||
- Normalize legacy portal JSON (uploadOnly) with new allowDownload checkbox semantics
|
||||
- Disable portal upload UI when uploads are turned off; hide refresh when downloads are disabled
|
||||
- Improve portal subtitles (“Upload & download”, “Upload only”, etc.) and status messaging
|
||||
- Add quick-access buttons in Client Portals modal for Add user, Folder access, and User groups
|
||||
- Enforce slug + folder as required on both frontend and backend, with inline hints and scroll-to-first-error
|
||||
- Auto-focus newly created portals’ folder input for faster setup
|
||||
- Raise user permissions modal z-index so it appears above the portals modal
|
||||
- Enhance portal form submission logging with better client IP detection (X-Forwarded-For / X-Real-IP aware)
|
||||
|
||||
---
|
||||
|
||||
## Changes 12/5/2025 (v2.3.4)
|
||||
|
||||
release(v2.3.4): fix(admin): use textContent for footer preview to satisfy CodeQL
|
||||
|
||||
---
|
||||
|
||||
## Changes 12/5/2025 (v2.3.3)
|
||||
|
||||
release(v2.3.3): footer branding, Pro bundle UX + file list polish
|
||||
|
||||
**Branding & footer**
|
||||
|
||||
- Added **Pro-only footer branding** (`branding.footerHtml`) stored in `adminConfig.json` and exposed via the Admin API.
|
||||
- Footer is now rendered from config; if no Pro footer is set, FileRise shows:
|
||||
`© YEAR FileRise` with a link to **filerise.net**.
|
||||
- New **“Header & Footer settings”** section in the Admin Panel, with a textarea for footer HTML (simple HTML + links allowed for Pro users).
|
||||
|
||||
**FileRise Pro & license UX**
|
||||
|
||||
- Bumped UI hint to `PRO_LATEST_BUNDLE_VERSION = v1.2.1`.
|
||||
- Pro bundle install now:
|
||||
- Parses the version from the uploaded ZIP basename (works with `C:\fakepath\FileRisePro-v1.2.1.zip`).
|
||||
- Invalidates OPcache for updated Pro files so new code is active immediately.
|
||||
- Re-fetches admin config after a successful install and displays the actual active Pro bundle version in the status line.
|
||||
- Admin config now exposes richer Pro metadata (plan, expiresAt, maxMajor), and the Admin Panel shows:
|
||||
- License type + email,
|
||||
- Friendly **plan** description (early supporter vs personal/business),
|
||||
- **Lifetime** vs **Valid until …** wording instead of a scary raw timestamp.
|
||||
|
||||
**Upload UX**
|
||||
|
||||
- Upload button is now only visible/enabled when there are files queued (regular or resumable):
|
||||
- Hidden when the list is empty or after clearing uploads.
|
||||
- Shown again when user picks or drags in files.
|
||||
- Adjusted Upload / Choose Files button sizing and spacing for a cleaner upload card, especially on smaller screens.
|
||||
|
||||
**File list & hover preview polish**
|
||||
|
||||
- Inline folders now respect the current sort mode:
|
||||
- **Name** sort: A–Z / Z–A.
|
||||
- **Size** sort: uses folder stats (bytes) and sorts accordingly.
|
||||
- Size and meta columns:
|
||||
- Right-aligned **size**, **uploaded/created**, **modified**, and **owner/uploader** columns.
|
||||
- Use tabular numerals for nicer numeric alignment.
|
||||
- Hover preview:
|
||||
- Skips “fake” rows (e.g. “No files found”) and rows that don’t resolve to a real file.
|
||||
- Uses `sizeBytes` + `formatSize()` for a consistent, human-readable size.
|
||||
- `formatSize()` now uses 1 decimal place (KB/MB/GB) and short `B` label for bytes.
|
||||
- File metadata normalization:
|
||||
- Every file gets a `sizeBytes`, normalized display `size`, and a `cacheKey` derived from modified/uploaded/size, used for stable cache-busting.
|
||||
- Gallery / preview URLs now use `apiFileUrl()` with a stable `t` parameter instead of `Date.now()`, improving browser caching behavior.
|
||||
|
||||
**Layout & animation tweaks**
|
||||
|
||||
- Slightly reduced default upload card padding and button sizes to make the homepage cards feel less “tall”.
|
||||
- New **site footer** styling (subtle border, centered text) added below the main layout.
|
||||
- Drag-and-drop card (upload/folder cards to header dock) animations:
|
||||
- Crisper ghost cards with better text opacity and anti-jank tweaks.
|
||||
- Longer, smoother easing and more readable motion (both collapse-to-header and expand-from-header).
|
||||
|
||||
---
|
||||
|
||||
## Changes 12/3/2025 (v2.3.2)
|
||||
|
||||
release(v2.3.2): fix media preview URLs and tighten hover card layout
|
||||
|
||||
- Reuse the working preview URL as a base when stepping between images/videos
|
||||
so next/prev navigation keeps using the same inline/download endpoint
|
||||
- Preserve video progress tracking and watched badges while fixing black-screen
|
||||
playback issues across browsers
|
||||
- Slightly shrink the file hover preview card (width/height, grid columns,
|
||||
gaps, snippet/props heights) for a more compact, less intrusive peek
|
||||
|
||||
---
|
||||
|
||||
## Changes 12/3/2025 (v2.3.1)
|
||||
|
||||
release(v2.3.1): polish file list actions & hover preview peak
|
||||
|
||||
- Replace per-row action button stack with compact 3-dot “More actions” menu in file list and folder tree
|
||||
- Add desktop hover preview peak card for files & folders (image thumb, text snippet, quick metadata)
|
||||
- Add per-user toggle to disable file hover preview (stored in localStorage)
|
||||
- Improve preview overlay: add Download button, Zoom/Rotate labels, keep download target in sync when navigating images/videos
|
||||
- Fix mobile table layout so Size column is visible for files & folders
|
||||
- Tweak dark/light glassmorphism styles for hover card and action buttons
|
||||
- Clean up size parsing and editable flag logic for big/unknown files
|
||||
|
||||
---
|
||||
|
||||
## Changes 12/2/2025 (v2.3.0)
|
||||
|
||||
release(v2.3.0): feat(portals): branding, intake presets, limits & CSV export
|
||||
|
||||
**v2.3.0 – Portal branding, intake presets & upload limits**
|
||||
|
||||
**Client portals (Pro)**
|
||||
|
||||
- Added **per-portal branding**:
|
||||
- Custom accent color and footer text, applied to both the portal page and the login card.
|
||||
- Optional **portal logo** stored under `uploads/profile_pics`, with a simple upload flow from the Client Portals modal.
|
||||
- Upgraded the **intake form**:
|
||||
- Per-field labels, defaults, visibility, and "required" switches for Name, Email, Reference, and Notes.
|
||||
- New presets for common workflows: **Legal intake**, **Tax client**, and **Order / RMA** that pre-fill labels and hints.
|
||||
- New **thank-you screen**:
|
||||
- Optional “Thank you” message shown after successful uploads, configurable per portal.
|
||||
- New **upload rules per portal**:
|
||||
- Max file size (MB) override.
|
||||
- Allowed extensions whitelist (comma-separated).
|
||||
- Simple per-browser daily upload limit, enforced in the portal UI with clear messaging.
|
||||
- Improved **portal description**:
|
||||
- Portal page now shows active rules (max size, allowed types, daily limit) so clients know what’s allowed.
|
||||
- **Submissions block** in the Client Portals modal:
|
||||
- Inline list of portal submissions with timestamps, folder, submitter and IP.
|
||||
- “Load submissions” button with paging-style UI and improved styling in both light and dark mode.
|
||||
- (New) **Export to CSV** action from the submissions block for easier reporting and audits.
|
||||
|
||||
**Portal login**
|
||||
|
||||
- Portal login screen now respects **per-portal branding**:
|
||||
- Uses the portal’s logo (or falls back to the default FileRise logo).
|
||||
- Reuses accent color and footer text from portal metadata so login matches the portal look.
|
||||
|
||||
**Admin panel**
|
||||
|
||||
- Added dedicated **Client Portals** editor section with:
|
||||
- Portal slug / label, folder picker, expiry, upload/download options.
|
||||
- Branding, logo upload, intake presets, upload limits, thank-you message, and live submissions preview.
|
||||
- Wired up new **ONLYOFFICE** admin section:
|
||||
- Toggle, document server origin, JWT secret management, plus built-in connection tests and CSP helper.
|
||||
- Wired up **Sponsor** section helper with copy-to-clipboard convenience for support links.
|
||||
- Moved a bunch of admin-panel specific styles into `styles.css` for better maintainability (modal sizing, section headers, dark-mode tweaks).
|
||||
|
||||
**File Preview**
|
||||
|
||||
- Remember the user’s volume (and mute state) in localStorage and re-apply it for every video preview in browser.
|
||||
|
||||
**Security / hardening**
|
||||
|
||||
- New `public/api/pro/portals/uploadLogo.php` endpoint for portal logos:
|
||||
- Pro-only, admin-only, CSRF-protected.
|
||||
- Accepts JPEG/PNG/GIF up to 2MB and stores them under `UPLOAD_DIR/profile_pics` with randomised names.
|
||||
|
||||
_No breaking changes expected; existing portals continue to work with default settings._
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/30/2025 (v2.2.4)
|
||||
|
||||
release(v2.2.4): fix(admin): ONLYOFFICE JWT save crash and respect replace/locked flags
|
||||
|
||||
- Prevented a JS crash when the ONLYOFFICE JWT field isn’t present by always initializing payload.onlyoffice before touching jwtSecret.
|
||||
- Tightened ONLYOFFICE JWT handling so the secret is only sent when config isn’t locked by PHP and the admin explicitly chooses Replace (or is setting it for the first time), instead of always pushing whatever is in the field.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/29/2025 (v2.2.3)
|
||||
|
||||
fix(preview): harden SVG handling and normalize mime type
|
||||
release(v2.2.3): round gallery card corners in file grid
|
||||
|
||||
- Stop treating SVGs as inline-previewable images in file list and preview modal
|
||||
- Show a clear “SVG preview disabled for security reasons” message instead
|
||||
- Keep SVGs downloadable via /api/file/download.php with proper image/svg+xml MIME
|
||||
- Add i18n key for svg_preview_disabled
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/29/2025 (v2.2.2)
|
||||
|
||||
release(v2.2.2): feat(folders): show inline folder stats & dates
|
||||
|
||||
- Extend FolderModel::countVisible() to track earliest and latest file mtimes
|
||||
- Format folder created/modified timestamps via DATE_TIME_FORMAT on the backend
|
||||
- Add a small folder stats cache in fileListView.js to reuse isEmpty.php responses
|
||||
- Use shared fetchFolderStats() for both folder strip icons and inline folder rows
|
||||
- Show per-folder item counts, total size, and created/modified dates in inline rows
|
||||
- Make size parsing more robust by accepting multiple backend size keys (bytes/sizeBytes/size/totalBytes)
|
||||
|
||||
---
|
||||
|
||||
## 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 doesn’t 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 they’re 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 you’re at top level vs nested folders.
|
||||
|
||||
- **Routing**
|
||||
- Apache rewrite added for pretty portal URLs:
|
||||
`https://host/portal/<slug>` → `portal.html?slug=<slug>` without affecting other routes.
|
||||
|
||||
### Pro v1.1.0 – Client Portals
|
||||
|
||||
- **Client portal definitions (Admin → FileRise Pro → Client Portals)**
|
||||
- Create multiple portals, each with:
|
||||
- Slug + display name
|
||||
- Target folder
|
||||
- Optional client email
|
||||
- Upload-only / allow-download flags
|
||||
- Per-portal expiry date
|
||||
- Portal-level copy and branding:
|
||||
- Optional title + instructions
|
||||
- Accent color used throughout the portal UI
|
||||
- Footer text at bottom of the portal page
|
||||
|
||||
- **Optional intake form before uploads**
|
||||
- Enable a form per portal with fields: name, email, reference, notes.
|
||||
- Per-field “default value” and “required” toggles.
|
||||
- Form must be completed before uploads when enabled.
|
||||
|
||||
- **Submissions log**
|
||||
- Each portal keeps a submissions list showing:
|
||||
- Date/time, folder, submitting user, IP address
|
||||
- The intake form values (name, email, reference, notes).
|
||||
|
||||
- **Client-facing experience**
|
||||
- New portal UI with:
|
||||
- Branded header (title + accent color)
|
||||
- Optional intake form
|
||||
- Drag-and-drop upload dropzone
|
||||
- If downloads are enabled, a clean list/grid of files already in that portal’s folder with download buttons.
|
||||
|
||||
- **Portal login page**
|
||||
- Minimal login screen that pulls title/accent/footer from portal metadata.
|
||||
- After successful login, user is redirected back to the original portal URL.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/21/2025 (v1.9.14)
|
||||
|
||||
release(v1.9.14): inline folder rows, synced folder icons, and compact theme polish
|
||||
|
||||
- Add ACL-aware folder stats and byte counts in FolderModel::countVisible()
|
||||
- Show subfolders inline as rows above files in table view (Explorer-style)
|
||||
- Page folders + files together and wire folder rows into existing DnD and context menu flows
|
||||
- Add folder action buttons (move/rename/color/share) with capability checks from /api/folder/capabilities.php
|
||||
- Cache folder capabilities and owners to avoid repeat calls per row
|
||||
- Add user settings to toggle folder strip and inline folder rows (stored in localStorage)
|
||||
- Default itemsPerPage to 50 and remember current page across renders
|
||||
- Sync inline folder icon size to file row height and tweak vertical alignment for different row heights
|
||||
- Update table headers + i18n keys to use Name / Size / Modified / Created / Owner labels
|
||||
- Compact and consolidate light/dark theme CSS, search pill, pagination, and font-size controls
|
||||
- Tighten file action button hit areas and add specific styles for folder move/rename buttons
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/20/2025 (v1.9.13)
|
||||
|
||||
release(v1.9.13): style(ui): compact dual-theme polish for lists, inputs, search & modals
|
||||
|
||||
- Added compact, unified light/dark theme for core surfaces (file list, upload, folder manager, admin panel).
|
||||
- Updated modals, dropdown menus, and editor header to use the same modern panel styling in both themes.
|
||||
- Restyled search bar into a pill-shaped control with a dedicated icon chip and better hover states.
|
||||
- Refined pagination (Prev/Next) and font size (A-/A+) buttons to be smaller, rounded, and more consistent.
|
||||
- Normalized input fields so borders render cleanly and focus states are consistent across the app.
|
||||
- Tweaked button shadows so primary actions (Create/Upload) pop without feeling heavy in light mode.
|
||||
- Polished dark-mode colors for tables, rows, toasts, and meta text for a more “app-like” feel.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/19/2025 (v1.9.12)
|
||||
|
||||
release(v1.9.12): feat(pro-acl): add user groups and group-aware ACL
|
||||
|
||||
- Add Pro user groups as a first-class ACL source:
|
||||
- Load group grants from FR_PRO_BUNDLE_DIR/groups.json in ACL::hasGrant().
|
||||
- Treat group grants as additive only; they can never remove access.
|
||||
|
||||
- Introduce AclAdminController:
|
||||
- Move getGrants/saveGrants logic into a dedicated controller.
|
||||
- Keep existing ACL normalization and business rules (shareFolder ⇒ view, shareFile ⇒ at least viewOwn).
|
||||
- Refactor public/api/admin/acl/getGrants.php and saveGrants.php to use the controller.
|
||||
|
||||
- Implement Pro user group storage and APIs:
|
||||
- Add ProGroups store class under FR_PRO_BUNDLE_DIR (groups.json with {name,label,members,grants}).
|
||||
- Add /api/pro/groups/list.php and /api/pro/groups/save.php, guarded by AdminController::requireAuth/requireAdmin/requireCsrf().
|
||||
- Keep groups and bundle code behind FR_PRO_ACTIVE/FR_PRO_BUNDLE_DIR checks.
|
||||
|
||||
- Ship Pro-only endpoints from core instead of the bundle:
|
||||
- Move public/api/pro/uploadBrandLogo.php into core and gate it on FR_PRO_ACTIVE.
|
||||
- Remove start.sh logic that copied public/api/pro from the Pro bundle into the container image.
|
||||
|
||||
- Extend admin UI for user groups:
|
||||
- Turn “User groups” into a real Pro-only modal with add/delete groups, multi-select members, and member chips.
|
||||
- Add “Edit folder access” for each group, reusing the existing folder grants grid.
|
||||
- Overlay group grants when editing a user’s ACL:
|
||||
- Show which caps are coming from groups, lock those checkboxes, and update tooltips.
|
||||
- Show group membership badges in the user permissions list.
|
||||
- Add a collapsed “Groups” section at the top of the permissions screen to preview group ACLs (read-only).
|
||||
|
||||
- Misc:
|
||||
- Bump PRO_LATEST_BUNDLE_VERSION hint in adminPanel.js to v1.0.1.
|
||||
- Tweak modal border-radius styling to include the new userGroups and groupAcl modals.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/18/2025 (v1.9.11)
|
||||
|
||||
release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68)
|
||||
|
||||
- media: add proper HTTP Range support to /api/file/download.php so HTML5
|
||||
video/audio can seek correctly across all browsers (Brave/Chrome/Android/Windows).
|
||||
- media: avoid buffering the entire file in memory; stream from disk with
|
||||
200/206 responses and Accept-Ranges for smoother playback and faster start times.
|
||||
- media: keep video progress tracking, watched badges, and status chip behavior
|
||||
unchanged but now compatible with the new streaming endpoint.
|
||||
|
||||
- ui: update the folder strip to be responsive:
|
||||
- desktop: keep the existing "chip" layout with icon above name.
|
||||
- mobile: switch to inline rows `[icon] [name]` with reduced whitespace.
|
||||
- ui: add simple lazy-loading for the folder strip so only the first batch of
|
||||
folders is rendered initially, with a "Load more…" button to append chunks for
|
||||
very large folder sets (stays friendly with 100k+ folders).
|
||||
|
||||
- misc: small CSS tidy-up around the folder strip classes to remove duplicates
|
||||
and keep mobile/desktop behavior clearly separated.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/18/2025 (v1.9.10)
|
||||
|
||||
release(v1.9.10): add Pro bundle installer and admin panel polish
|
||||
|
||||
|
||||
157
README.md
@@ -7,25 +7,30 @@
|
||||
[](https://demo.filerise.net)
|
||||
[](https://github.com/error311/FileRise/releases)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/7WN6f56X2e)
|
||||
[](https://github.com/sponsors/error311)
|
||||
[](https://ko-fi.com/error311)
|
||||
|
||||
**FileRise** is a modern, self‑hosted web file manager / WebDAV server.
|
||||
Drag & drop uploads, ACL‑aware sharing, OnlyOffice integration, and a clean UI — all in a single PHP app that you control.
|
||||
**FileRise** is a modern, self-hosted web file manager / WebDAV server.
|
||||
Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI — all in a single PHP app that you control.
|
||||
|
||||
- 💾 **Self‑hosted “cloud drive”** – Runs anywhere with PHP (or via Docker). No external DB required.
|
||||
- 🔐 **Granular per‑folder ACLs** – View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
|
||||
- 🔄 **Fast drag‑and‑drop uploads** – Chunked, resumable uploads with pause/resume and progress.
|
||||
- 💾 **Self-hosted “cloud drive”** – Runs anywhere with PHP (or via Docker). No external DB required.
|
||||
- 🔐 **Granular per-folder ACLs** – View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
|
||||
- 🔄 **Fast drag-and-drop uploads** – Chunked, resumable uploads with pause/resume and progress.
|
||||
- 🌳 **Scales to huge trees** – Tested with **100k+ folders** in the sidebar tree.
|
||||
- 🧩 **ONLYOFFICE support (optional)** – Edit DOCX/XLSX/PPTX using your own Document Server.
|
||||
- 🌍 **WebDAV** – Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP.
|
||||
- 🎨 **Polished UI** – Dark/light mode, responsive layout, in‑browser previews & code editor.
|
||||
- 📊 **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.
|
||||
|
||||

|
||||
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 open‑source (MIT).
|
||||

|
||||
|
||||
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
|
||||
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open-source (MIT).
|
||||
|
||||
---
|
||||
|
||||
@@ -37,21 +42,22 @@ Drag & drop uploads, ACL‑aware sharing, OnlyOffice integration, and a clean UI
|
||||
- [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
|
||||
- [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
|
||||
- 🐳 **Docker image:** [Docker](https://github.com/error311/filerise-docker)
|
||||
- 💬 **Discord:** [Join the FileRise server](https://discord.gg/YOUR_CODE_HERE)
|
||||
- 📝 **Changelog:** [Changes](https://github.com/error311/FileRise/blob/master/CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. What FileRise does
|
||||
|
||||
FileRise turns a folder on your server into a **web‑based file explorer** with:
|
||||
FileRise turns a folder on your server into a **web-based file explorer** with:
|
||||
|
||||
- Folder tree + breadcrumbs for fast navigation
|
||||
- Multi‑file/folder drag‑and‑drop uploads
|
||||
- Multi-file/folder drag-and-drop uploads
|
||||
- Move / copy / rename / delete / extract ZIP
|
||||
- Public share links (optionally password‑protected & expiring)
|
||||
- Public share links (optionally password-protected & expiring)
|
||||
- Tagging and search by name, tag, uploader, and content
|
||||
- Trash with restore/purge
|
||||
- Inline previews (images, audio, video, PDF) and a built‑in code editor
|
||||
- Inline previews (images, audio, video, PDF) and a built-in code editor
|
||||
|
||||
Everything flows through a single ACL engine, so permissions are enforced consistently whether users are in the browser UI, using WebDAV, or hitting the API.
|
||||
|
||||
@@ -61,8 +67,22 @@ Everything flows through a single ACL engine, so permissions are enforced consis
|
||||
|
||||
The easiest way to run FileRise is the official Docker image.
|
||||
|
||||
### Option A – Quick start (docker run)
|
||||
|
||||
```bash
|
||||
docker run -d --name filerise -p 8080:80 -e TIMEZONE="America/New_York" -e PERSISTENT_TOKENS_KEY="change_me_to_a_random_string" -v ~/filerise/uploads:/var/www/uploads -v ~/filerise/users:/var/www/users -v ~/filerise/metadata:/var/www/metadata error311/filerise-docker:latest
|
||||
docker run -d \
|
||||
--name filerise \
|
||||
-p 8080:80 \
|
||||
-e TIMEZONE="America/New_York" \
|
||||
-e TOTAL_UPLOAD_SIZE="10G" \
|
||||
-e SECURE="false" \
|
||||
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
|
||||
-e SCAN_ON_START="true" \
|
||||
-e CHOWN_ON_START="true" \
|
||||
-v ~/filerise/uploads:/var/www/uploads \
|
||||
-v ~/filerise/users:/var/www/users \
|
||||
-v ~/filerise/metadata:/var/www/metadata \
|
||||
error311/filerise-docker:latest
|
||||
```
|
||||
|
||||
Then visit:
|
||||
@@ -73,19 +93,97 @@ http://your-server-ip:8080
|
||||
|
||||
On first launch you’ll be guided through creating the **initial admin user**.
|
||||
|
||||
**More Docker options (Unraid, docker‑compose, env vars, reverse proxy, etc.)**
|
||||
See the Docker repo: [docker repo](https://github.com/error311/filerise-docker)
|
||||
> 💡 After the first run, you can set `CHOWN_ON_START="false"` if permissions are already correct and you don’t want a recursive `chown` on every start.
|
||||
|
||||
> ⚠️ **Uploads folder recommendation**
|
||||
>
|
||||
> It’s strongly recommended to bind `/var/www/uploads` to a **dedicated folder**
|
||||
> (for example `~/filerise/uploads` or `/mnt/user/appdata/FileRise/uploads`),
|
||||
> not the root of a huge media share.
|
||||
>
|
||||
> If you really want FileRise to sit “on top of” an existing share, use a
|
||||
> subfolder (e.g. `/mnt/user/media/filerise_root`) instead of the share root,
|
||||
> so scans and permission changes stay scoped to that folder.
|
||||
|
||||
---
|
||||
|
||||
### Option B – docker-compose.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
filerise:
|
||||
image: error311/filerise-docker:latest
|
||||
container_name: filerise
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
TIMEZONE: "America/New_York"
|
||||
TOTAL_UPLOAD_SIZE: "10G"
|
||||
SECURE: "false"
|
||||
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
|
||||
SCAN_ON_START: "true" # auto-index existing files on startup
|
||||
CHOWN_ON_START: "true" # fix permissions on uploads/users/metadata on startup
|
||||
volumes:
|
||||
- ./uploads:/var/www/uploads
|
||||
- ./users:/var/www/users
|
||||
- ./metadata:/var/www/metadata
|
||||
```
|
||||
|
||||
Bring it up with:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Common environment variables
|
||||
|
||||
| Variable | Required | Example | What it does |
|
||||
|-------------------------|----------|----------------------------------|-------------------------------------------------------------------------------|
|
||||
| `TIMEZONE` | ✅ | `America/New_York` | PHP / container timezone. |
|
||||
| `TOTAL_UPLOAD_SIZE` | ✅ | `10G` | Max total upload size per request (e.g. `5G`, `10G`). |
|
||||
| `SECURE` | ✅ | `false` | `true` when running behind HTTPS / reverse proxy, else `false`. |
|
||||
| `PERSISTENT_TOKENS_KEY` | ✅ | `default_please_change_this_key` | Secret used to sign “remember me” tokens. **Change this.** |
|
||||
| `SCAN_ON_START` | Optional | `true` | If `true`, scan `uploads/` on startup and index existing files. |
|
||||
| `CHOWN_ON_START` | Optional | `true` | If `true`, chown `uploads/`, `users/`, `metadata/` on startup. |
|
||||
| `DATE_TIME_FORMAT` | Optional | `Y-m-d H:i` | Overrides `DATE_TIME_FORMAT` in `config.php` (controls how dates are shown). |
|
||||
|
||||
> If `DATE_TIME_FORMAT` is not set, FileRise uses the default from `config/config.php`
|
||||
> (currently `m/d/y h:iA`).
|
||||
> 🗂 **Using an existing folder tree**
|
||||
>
|
||||
> - Point `/var/www/uploads` at the folder you want FileRise to manage.
|
||||
> - Set `SCAN_ON_START="true"` on the first run to index existing files, then
|
||||
> usually set it to `"false"` so the container doesn’t rescan on every restart.
|
||||
> - `CHOWN_ON_START="true"` is handy on first run to fix permissions. If you map
|
||||
> a large share or already manage ownership yourself, set it to `"false"` to
|
||||
> avoid recursive `chown` on every start.
|
||||
>
|
||||
> Volumes:
|
||||
> - `/var/www/uploads` – your actual files
|
||||
> - `/var/www/users` – user & pro jsons
|
||||
> - `/var/www/metadata` – tags, search index, share links, etc.
|
||||
|
||||
**More Docker / orchestration options (Unraid, Portainer, k8s, 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)
|
||||
- [Kubernetes / k8s deployment](https://github.com/error311/FileRise/wiki/Kubernetes---k8s-deployment)
|
||||
- Portainer templates: add this URL in Portainer → Settings → App Templates:
|
||||
`https://raw.githubusercontent.com/error311/filerise-portainer-templates/refs/heads/main/templates.json`
|
||||
- See also the Docker repo: [error311/filerise-docker](https://github.com/error311/filerise-docker)
|
||||
|
||||
---
|
||||
|
||||
## 3. Manual install (PHP web server)
|
||||
|
||||
Prefer bare‑metal or your own stack? FileRise is just PHP + a few extensions.
|
||||
Prefer bare-metal or your own stack? FileRise is just PHP + a few extensions.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- PHP **8.3+**
|
||||
- Web server (Apache / Nginx / Caddy + PHP‑FPM)
|
||||
- Web server (Apache / Nginx / Caddy + PHP-FPM)
|
||||
- PHP extensions: `json`, `curl`, `zip` (and usual defaults)
|
||||
- No database required
|
||||
|
||||
@@ -118,7 +216,7 @@ Prefer bare‑metal or your own stack? FileRise is just PHP + a few extensions.
|
||||
|
||||
5. Browse to your FileRise URL and follow the **admin setup** screen.
|
||||
|
||||
For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki.
|
||||
For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki [Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup).
|
||||
|
||||
---
|
||||
|
||||
@@ -139,14 +237,14 @@ See: [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
|
||||
|
||||
### ONLYOFFICE integration
|
||||
|
||||
If you run an ONLYOFFICE Document Server you can open/edit Office documents directly from FileRise (DOCX, XLSX, PPTX, ODT, ODS, ODP; PDFs view‑only).
|
||||
If you run an ONLYOFFICE Document Server you can open/edit Office documents directly from FileRise (DOCX, XLSX, PPTX, ODT, ODS, ODP; PDFs view-only).
|
||||
|
||||
Configure it in **Admin → ONLYOFFICE**:
|
||||
|
||||
- Enable ONLYOFFICE
|
||||
- Set your Document Server origin (e.g. `https://docs.example.com`)
|
||||
- Configure a shared JWT secret
|
||||
- Copy the suggested Content‑Security‑Policy header into your reverse proxy
|
||||
- Copy the suggested Content-Security-Policy header into your reverse proxy
|
||||
|
||||
Docs: [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
|
||||
|
||||
@@ -167,8 +265,8 @@ Please report vulnerabilities responsibly via the channels listed in **SECURITY.
|
||||
## 6. Community, support & contributing
|
||||
|
||||
- 🧵 **GitHub Discussions & Issues:** ask questions, report bugs, suggest features.
|
||||
- 💬 **Unraid forum thread:** for Unraid‑specific setup and tuning.
|
||||
- 🌍 **Reddit / self‑hosting communities:** occasional release posts & feedback threads.
|
||||
- 💬 **Unraid forum thread:** for Unraid-specific setup and tuning.
|
||||
- 🌍 **Reddit / self-hosting communities:** occasional release posts & feedback threads.
|
||||
|
||||
Contributions are welcome — from bug fixes and docs to translations and UI polish.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
@@ -176,15 +274,20 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
If FileRise saves you time or becomes your daily driver, a ⭐ on GitHub or sponsorship is hugely appreciated:
|
||||
|
||||
- ❤️ [GitHub Sponsors](https://github.com/sponsors/error311)
|
||||
- ☕ [Ko‑fi](https://ko-fi.com/error311)
|
||||
- ☕ [Ko-fi](https://ko-fi.com/error311)
|
||||
|
||||
---
|
||||
|
||||
## 7. License & third‑party code
|
||||
## 7. License & third-party code
|
||||
|
||||
FileRise Core is released under the **MIT License** – see [LICENSE](LICENSE).
|
||||
|
||||
It bundles a small set of well‑known client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
|
||||
All third‑party code remains under its original licenses.
|
||||
It bundles a small set of well-known client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
|
||||
All third-party 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)
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -26,6 +26,7 @@ RewriteRule - - [L]
|
||||
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
|
||||
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
||||
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
||||
RewriteRule ^portal/([A-Za-z0-9_-]+)$ portal.html?slug=$1 [L,QSA]
|
||||
|
||||
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
|
||||
# - allow /api/*.php (API endpoints)
|
||||
|
||||
@@ -3,83 +3,26 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
|
||||
http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit;
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = trim((string)($_GET['user'] ?? ''));
|
||||
if ($user === '' || !preg_match(REGEX_USER, $user)) {
|
||||
http_response_code(400); echo json_encode(['error'=>'Invalid user']); exit;
|
||||
}
|
||||
|
||||
// Build the folder list (admin sees all)
|
||||
$folders = [];
|
||||
try {
|
||||
$rows = FolderModel::getFolderList();
|
||||
if (is_array($rows)) {
|
||||
foreach ($rows as $r) {
|
||||
$f = is_array($r) ? ($r['folder'] ?? '') : (string)$r;
|
||||
if ($f !== '') $folders[$f] = true;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) { /* ignore */ }
|
||||
|
||||
if (empty($folders)) {
|
||||
$aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||
if (is_file($aclPath)) {
|
||||
$data = json_decode((string)@file_get_contents($aclPath), true);
|
||||
if (is_array($data['folders'] ?? null)) {
|
||||
foreach ($data['folders'] as $name => $_) $folders[$name] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$folderList = array_keys($folders);
|
||||
if (!in_array('root', $folderList, true)) array_unshift($folderList, 'root');
|
||||
|
||||
$has = function(array $arr, string $u): bool {
|
||||
foreach ($arr as $x) if (strcasecmp((string)$x, $u) === 0) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
$out = [];
|
||||
foreach ($folderList as $f) {
|
||||
$rec = ACL::explicitAll($f); // legacy + granular
|
||||
|
||||
$isOwner = $has($rec['owners'], $user);
|
||||
$canViewAll = $isOwner || $has($rec['read'], $user);
|
||||
$canViewOwn = $has($rec['read_own'], $user);
|
||||
$canShare = $isOwner || $has($rec['share'], $user);
|
||||
$canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user);
|
||||
|
||||
if ($canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner
|
||||
|| $has($rec['create'],$user) || $has($rec['edit'],$user) || $has($rec['rename'],$user)
|
||||
|| $has($rec['copy'],$user) || $has($rec['move'],$user) || $has($rec['delete'],$user)
|
||||
|| $has($rec['extract'],$user) || $has($rec['share_file'],$user) || $has($rec['share_folder'],$user)) {
|
||||
$out[$f] = [
|
||||
'view' => $canViewAll,
|
||||
'viewOwn' => $canViewOwn,
|
||||
'write' => $has($rec['write'], $user) || $isOwner,
|
||||
'manage' => $isOwner,
|
||||
'share' => $canShare, // legacy
|
||||
'create' => $isOwner || $has($rec['create'], $user),
|
||||
'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'],$user),
|
||||
'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'],$user),
|
||||
'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'],$user),
|
||||
'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'],$user),
|
||||
'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'],$user),
|
||||
'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'],$user),
|
||||
'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'],$user),
|
||||
'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'],$user),
|
||||
'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'],$user),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);
|
||||
$ctrl = new AclAdminController();
|
||||
$grants = $ctrl->getUserGrants($user);
|
||||
echo json_encode(['grants' => $grants], JSON_UNESCAPED_SLASHES);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to load grants', 'detail' => $e->getMessage()]);
|
||||
}
|
||||
@@ -3,12 +3,11 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// ---- Auth + CSRF -----------------------------------------------------------
|
||||
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
@@ -24,98 +23,17 @@ if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------
|
||||
function normalize_caps(array $row): array {
|
||||
// booleanize known keys
|
||||
$bool = function($v){ return !empty($v) && $v !== 'false' && $v !== 0; };
|
||||
$k = [
|
||||
'view','viewOwn','upload','manage','share',
|
||||
'create','edit','rename','copy','move','delete','extract',
|
||||
'shareFile','shareFolder','write'
|
||||
];
|
||||
$out = [];
|
||||
foreach ($k as $kk) $out[$kk] = $bool($row[$kk] ?? false);
|
||||
|
||||
// BUSINESS RULES:
|
||||
// A) Share Folder REQUIRES View (all). If shareFolder is true but view is false, force view=true.
|
||||
if ($out['shareFolder'] && !$out['view']) {
|
||||
$out['view'] = true;
|
||||
}
|
||||
|
||||
// B) Share File requires at least View (own). If neither view nor viewOwn set, set viewOwn=true.
|
||||
if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) {
|
||||
$out['viewOwn'] = true;
|
||||
}
|
||||
|
||||
// C) "write" does NOT imply view. It also does not imply granular here; ACL expands legacy write if present.
|
||||
return $out;
|
||||
}
|
||||
|
||||
function sanitize_grants_map(array $grants): array {
|
||||
$out = [];
|
||||
foreach ($grants as $folder => $caps) {
|
||||
if (!is_string($folder)) $folder = (string)$folder;
|
||||
if (!is_array($caps)) $caps = [];
|
||||
$out[$folder] = normalize_caps($caps);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function valid_user(string $u): bool {
|
||||
return ($u !== '' && preg_match(REGEX_USER, $u));
|
||||
}
|
||||
|
||||
// ---- Read JSON body --------------------------------------------------------
|
||||
$raw = file_get_contents('php://input');
|
||||
$in = json_decode((string)$raw, true);
|
||||
if (!is_array($in)) {
|
||||
|
||||
try {
|
||||
$ctrl = new AclAdminController();
|
||||
$res = $ctrl->saveUserGrantsPayload($in ?? []);
|
||||
echo json_encode($res, JSON_UNESCAPED_SLASHES);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid JSON']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---- Single user mode: { user, grants } ------------------------------------
|
||||
if (isset($in['user']) && isset($in['grants']) && is_array($in['grants'])) {
|
||||
$user = trim((string)$in['user']);
|
||||
if (!valid_user($user)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid user']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$grants = sanitize_grants_map($in['grants']);
|
||||
|
||||
try {
|
||||
$res = ACL::applyUserGrantsAtomic($user, $grants);
|
||||
echo json_encode($res, JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Batch mode: { changes: [ { user, grants }, ... ] } --------------------
|
||||
if (isset($in['changes']) && is_array($in['changes'])) {
|
||||
$updated = [];
|
||||
foreach ($in['changes'] as $chg) {
|
||||
if (!is_array($chg)) continue;
|
||||
$user = trim((string)($chg['user'] ?? ''));
|
||||
$gr = $chg['grants'] ?? null;
|
||||
if (!valid_user($user) || !is_array($gr)) continue;
|
||||
|
||||
try {
|
||||
$res = ACL::applyUserGrantsAtomic($user, sanitize_grants_map($gr));
|
||||
$updated[$user] = $res['updated'] ?? [];
|
||||
} catch (Throwable $e) {
|
||||
$updated[$user] = ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
echo json_encode(['ok' => true, 'updated' => $updated], JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---- Fallback --------------------------------------------------------------
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]);
|
||||
}
|
||||
41
public/api/admin/diskUsageSummary.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
102
public/api/admin/diskUsageTriggerScan.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
53
public/api/pro/diskUsageChildren.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
55
public/api/pro/diskUsageDeleteFilePermanent.php
Normal 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']);
|
||||
}
|
||||
60
public/api/pro/diskUsageDeleteFolderRecursive.php
Normal 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']);
|
||||
}
|
||||
51
public/api/pro/diskUsageTopFiles.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
32
public/api/pro/groups/list.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// public/api/pro/groups/list.php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
try {
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
AdminController::requireAuth();
|
||||
AdminController::requireAdmin();
|
||||
|
||||
$ctrl = new AdminController();
|
||||
$groups = $ctrl->getProGroups();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'groups' => $groups,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
$code = $e instanceof InvalidArgumentException ? 400 : 500;
|
||||
http_response_code($code);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Error loading groups: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
51
public/api/pro/groups/save.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
// public/api/pro/groups/save.php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
try {
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
AdminController::requireAuth();
|
||||
AdminController::requireAdmin();
|
||||
AdminController::requireCsrf();
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid JSON payload.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$groups = $body['groups'] ?? null;
|
||||
if (!is_array($groups)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid groups format.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ctrl = new AdminController();
|
||||
$ctrl->saveProGroups($groups);
|
||||
|
||||
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
$code = $e instanceof InvalidArgumentException ? 400 : 500;
|
||||
http_response_code($code);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Error saving groups: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
27
public/api/pro/portals/get.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// public/api/pro/portals/get.php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/PortalController.php';
|
||||
|
||||
try {
|
||||
$slug = isset($_GET['slug']) ? (string)$_GET['slug'] : '';
|
||||
|
||||
// For v1: we do NOT require auth here; this is just metadata,
|
||||
// real ACL/access control must still be enforced at upload/download endpoints.
|
||||
$portal = PortalController::getPortalBySlug($slug);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'portal' => $portal,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
32
public/api/pro/portals/list.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// public/api/pro/portals/list.php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
try {
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
AdminController::requireAuth();
|
||||
AdminController::requireAdmin();
|
||||
|
||||
$ctrl = new AdminController();
|
||||
$portals = $ctrl->getProPortals();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'portals' => $portals,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
$code = $e instanceof InvalidArgumentException ? 400 : 500;
|
||||
http_response_code($code);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
109
public/api/pro/portals/publicMeta.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
// public/api/pro/portals/publicMeta.php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
|
||||
// --- Basic Pro checks ---
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'FileRise Pro is not active.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$slug = isset($_GET['slug']) ? trim((string)$_GET['slug']) : '';
|
||||
if ($slug === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Missing portal slug.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Locate portals.json written by saveProPortals() ---
|
||||
$bundleDir = defined('FR_PRO_BUNDLE_DIR') ? (string)FR_PRO_BUNDLE_DIR : '';
|
||||
if ($bundleDir === '' || !is_dir($bundleDir)) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Pro bundle directory not found.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$jsonPath = rtrim($bundleDir, "/\\") . '/portals.json';
|
||||
if (!is_file($jsonPath)) {
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'No portals defined.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($jsonPath);
|
||||
if ($raw === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Could not read portals store.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Invalid portals store.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$portals = $data['portals'] ?? [];
|
||||
if (!is_array($portals) || !isset($portals[$slug]) || !is_array($portals[$slug])) {
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Portal not found.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$portal = $portals[$slug];
|
||||
|
||||
// Optional: handle expiry if you’re using expiresAt as ISO date string
|
||||
if (!empty($portal['expiresAt'])) {
|
||||
$ts = strtotime((string)$portal['expiresAt']);
|
||||
if ($ts !== false && $ts < time()) {
|
||||
http_response_code(410); // Gone
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'This portal has expired.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Only expose the bits the login page needs (no folder, email, etc.)
|
||||
$public = [
|
||||
'slug' => $slug,
|
||||
'label' => (string)($portal['label'] ?? ''),
|
||||
'title' => (string)($portal['title'] ?? ''),
|
||||
'introText' => (string)($portal['introText'] ?? ''),
|
||||
'brandColor' => (string)($portal['brandColor'] ?? ''),
|
||||
'footerText' => (string)($portal['footerText'] ?? ''),
|
||||
'logoFile' => (string)($portal['logoFile'] ?? ''),
|
||||
];
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'portal' => $public,
|
||||
]);
|
||||
51
public/api/pro/portals/save.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
// public/api/pro/portals/save.php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
try {
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
AdminController::requireAuth();
|
||||
AdminController::requireAdmin();
|
||||
AdminController::requireCsrf();
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||
return;
|
||||
}
|
||||
|
||||
$portals = $body['portals'] ?? null;
|
||||
if (!is_array($portals)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid or missing "portals" payload']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ctrl = new AdminController();
|
||||
$ctrl->saveProPortals($portals);
|
||||
|
||||
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
$code = $e instanceof InvalidArgumentException ? 400 : 500;
|
||||
http_response_code($code);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
64
public/api/pro/portals/submissions.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
|
||||
try {
|
||||
// --- Basic auth / admin check (keep it simple & consistent with your other admin APIs)
|
||||
@session_start();
|
||||
|
||||
$username = (string)($_SESSION['username'] ?? '');
|
||||
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||
|
||||
if ($username === '' || !$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Forbidden',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot done, release lock for concurrency
|
||||
@session_write_close();
|
||||
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
||||
throw new RuntimeException('FileRise Pro is not active.');
|
||||
}
|
||||
|
||||
$slug = isset($_GET['slug']) ? trim((string)$_GET['slug']) : '';
|
||||
if ($slug === '') {
|
||||
throw new InvalidArgumentException('Missing slug.');
|
||||
}
|
||||
|
||||
// Use your ProPortalSubmissions helper from the bundle
|
||||
$proSubmissionsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortalSubmissions.php';
|
||||
if (!is_file($proSubmissionsPath)) {
|
||||
throw new RuntimeException('ProPortalSubmissions.php not found in Pro bundle.');
|
||||
}
|
||||
require_once $proSubmissionsPath;
|
||||
|
||||
$store = new ProPortalSubmissions((string)FR_PRO_BUNDLE_DIR);
|
||||
$submissions = $store->listBySlug($slug, 200);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'slug' => $slug,
|
||||
'submissions' => $submissions,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Server error: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
112
public/api/pro/portals/submitForm.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?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'] ?? '');
|
||||
|
||||
// ─────────────────────────────
|
||||
// Better client IP detection
|
||||
// ─────────────────────────────
|
||||
$ip = '';
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
// Can be a comma-separated list; use the first non-empty
|
||||
$parts = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
foreach ($parts as $part) {
|
||||
$candidate = trim($part);
|
||||
if ($candidate !== '') {
|
||||
$ip = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
$ip = trim((string)$_SERVER['HTTP_X_REAL_IP']);
|
||||
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
$ip = trim((string)$_SERVER['REMOTE_ADDR']);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'slug' => $slug,
|
||||
'portalLabel' => $portal['label'] ?? '',
|
||||
'folder' => $portal['folder'] ?? '',
|
||||
'form' => [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'reference' => $reference,
|
||||
'notes' => $notes,
|
||||
],
|
||||
'submittedBy' => $submittedBy,
|
||||
'ip' => $ip,
|
||||
'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);
|
||||
}
|
||||
30
public/api/pro/portals/uploadLogo.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// public/api/pro/portals/uploadLogo.php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Pro-only gate
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'FileRise Pro is not active on this instance.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$ctrl = new UserController();
|
||||
$ctrl->uploadPortalLogo();
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Exception: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
28
public/api/pro/uploadBrandLogo.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
// public/api/pro/uploadBrandLogo.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Pro-only gate
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'FileRise Pro is not active on this instance.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$ctrl = new UserController();
|
||||
$ctrl->uploadBrandLogo();
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Exception: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
@@ -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">
|
||||
@@ -167,7 +188,7 @@
|
||||
<div class="card-header" data-i18n-key="upload_header">Upload Files/Folders</div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<form id="uploadFileForm" method="post" enctype="multipart/form-data" class="d-flex flex-column">
|
||||
<div class="form-group flex-grow-1" style="margin-bottom: 1rem;">
|
||||
<div class="form-group flex-grow-1" style="margin-bottom: 0rem;">
|
||||
<div id="uploadDropArea"
|
||||
style="border:2px dashed #ccc; padding:20px; cursor:pointer; display:flex; flex-direction:column; justify-content:center; align-items:center; position:relative;">
|
||||
<span data-i18n-key="upload_instruction">Drop files/folders here or click 'Choose
|
||||
@@ -178,7 +199,7 @@
|
||||
<button type="button" id="customChooseBtn" data-i18n-key="choose_files">Choose Files</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" id="uploadBtn" class="btn btn-primary d-block mx-auto"
|
||||
<button type="submit" id="uploadBtn" class="btn btn-primary mx-auto"
|
||||
data-i18n-key="upload">Upload</button>
|
||||
<div id="uploadProgressContainer"></div>
|
||||
</form>
|
||||
@@ -190,16 +211,12 @@
|
||||
<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 & 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;">
|
||||
<div id="folderTreeContainer"></div>
|
||||
</div>
|
||||
<div class="folder-actions mt-3">
|
||||
<div class="folder-actions">
|
||||
<button id="createFolderBtn" class="btn btn-primary" data-i18n-title="create_folder">
|
||||
<i class="material-icons">create_new_folder</i>
|
||||
</button>
|
||||
@@ -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 can’t 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;">
|
||||
@@ -542,5 +538,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer id="siteFooter" class="site-footer">
|
||||
<span>
|
||||
© 2025
|
||||
<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">
|
||||
FileRise
|
||||
</a>
|
||||
</span>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
511
public/js/adminOnlyOffice.js
Normal file
@@ -0,0 +1,511 @@
|
||||
// public/js/adminOnlyOffice.js
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
|
||||
/**
|
||||
* Translate with fallback
|
||||
*/
|
||||
const tf = (key, fallback) => {
|
||||
const v = t(key);
|
||||
return (v && v !== key) ? v : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Local masked-input renderer (copied from adminPanel.js style)
|
||||
*/
|
||||
function renderMaskedInput({ id, label, hasValue, isSecret = false }) {
|
||||
const type = isSecret ? 'password' : 'text';
|
||||
const disabled = hasValue
|
||||
? 'disabled data-replace="0" placeholder="•••••• (saved)"'
|
||||
: 'data-replace="1"';
|
||||
const replaceBtn = hasValue
|
||||
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
|
||||
: '';
|
||||
const note = hasValue
|
||||
? `<small class="text-success" style="margin-left:4px;">Saved — leave blank to keep</small>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label for="${id}">${label}:</label>
|
||||
<div style="display:flex; gap:8px; align-items:center;">
|
||||
<input type="${type}" id="${id}" class="form-control" ${disabled} />
|
||||
${replaceBtn}
|
||||
</div>
|
||||
${note}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local "Replace" wiring (copied from adminPanel.js style, but scoped)
|
||||
*/
|
||||
function wireReplaceButtons(scope = document) {
|
||||
scope.querySelectorAll('[data-replace-for]').forEach(btn => {
|
||||
if (btn.__wired) return;
|
||||
btn.__wired = true;
|
||||
btn.addEventListener('click', () => {
|
||||
const id = btn.getAttribute('data-replace-for');
|
||||
const inp = scope.querySelector('#' + id);
|
||||
if (!inp) return;
|
||||
inp.disabled = false;
|
||||
inp.dataset.replace = '1';
|
||||
inp.placeholder = '';
|
||||
inp.value = '';
|
||||
btn.textContent = 'Keep saved value';
|
||||
btn.removeAttribute('data-replace-for');
|
||||
btn.addEventListener('click', () => { /* no-op after first toggle */ }, { once: true });
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trusted origin helper (mirror of your inline logic)
|
||||
*/
|
||||
function getTrustedDocsOrigin(raw) {
|
||||
try {
|
||||
const u = new URL(String(raw || '').trim());
|
||||
if (!/^https?:$/.test(u.protocol)) return null; // only http/https
|
||||
if (u.username || u.password) return null; // no creds in URL
|
||||
return u.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildOnlyOfficeApiUrl(origin) {
|
||||
const u = new URL('/web-apps/apps/api/documents/api.js', origin);
|
||||
u.searchParams.set('probe', String(Date.now()));
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight JSON helper for this module
|
||||
*/
|
||||
async function safeJsonLocal(res) {
|
||||
const txt = await res.text();
|
||||
let body = null;
|
||||
try { body = txt ? JSON.parse(txt) : null; } catch { /* ignore */ }
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
(body && (body.error || body.message)) ||
|
||||
(txt && txt.trim()) ||
|
||||
`HTTP ${res.status}`;
|
||||
const err = new Error(msg);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return body ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Script probe for api.js (mirrors old ooProbeScript)
|
||||
*/
|
||||
async function ooProbeScript(docsOrigin) {
|
||||
return new Promise(resolve => {
|
||||
const base = getTrustedDocsOrigin(docsOrigin);
|
||||
if (!base) { resolve({ ok: false }); return; }
|
||||
|
||||
const src = buildOnlyOfficeApiUrl(base);
|
||||
const s = document.createElement('script');
|
||||
s.id = 'ooProbeScript';
|
||||
s.async = true;
|
||||
s.src = src;
|
||||
|
||||
const nonce = document.querySelector('meta[name="csp-nonce"]')?.content;
|
||||
if (nonce) s.setAttribute('nonce', nonce);
|
||||
|
||||
const cleanup = () => { try { s.remove(); } catch { /* ignore */ } };
|
||||
|
||||
s.onload = () => { cleanup(); resolve({ ok: true }); };
|
||||
s.onerror = () => { cleanup(); resolve({ ok: false }); };
|
||||
|
||||
// origin is validated, path is fixed => safe
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Iframe probe for DS (mirrors old ooProbeFrame)
|
||||
*/
|
||||
async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
|
||||
return new Promise(resolve => {
|
||||
const base = getTrustedDocsOrigin(docsOrigin);
|
||||
if (!base) { resolve({ ok: false }); return; }
|
||||
|
||||
const f = document.createElement('iframe');
|
||||
f.id = 'ooProbeFrame';
|
||||
f.src = base;
|
||||
f.style.display = 'none';
|
||||
|
||||
const cleanup = () => { try { f.remove(); } catch { /* ignore */ } };
|
||||
const t = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve({ ok: false, timeout: true });
|
||||
}, timeoutMs);
|
||||
|
||||
f.onload = () => {
|
||||
clearTimeout(t);
|
||||
cleanup();
|
||||
resolve({ ok: true });
|
||||
};
|
||||
f.onerror = () => {
|
||||
clearTimeout(t);
|
||||
cleanup();
|
||||
resolve({ ok: false });
|
||||
};
|
||||
|
||||
// src constrained to validated http/https origin
|
||||
document.body.appendChild(f);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy helpers (same behavior you had before)
|
||||
*/
|
||||
async function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectElementContents(el) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ONLYOFFICE test card and wires Run tests button
|
||||
*/
|
||||
function attachOnlyOfficeTests(container) {
|
||||
const testBox = document.createElement('div');
|
||||
testBox.className = 'card';
|
||||
testBox.style.marginTop = '12px';
|
||||
testBox.innerHTML = `
|
||||
<div class="card-body">
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
|
||||
<strong>Test ONLYOFFICE connection</strong>
|
||||
<button type="button" id="ooTestBtn" class="btn btn-sm btn-primary">Run tests</button>
|
||||
<span id="ooTestSpinner" style="display:none;">⏳</span>
|
||||
</div>
|
||||
<ul id="ooTestResults" class="list-unstyled" style="margin:0;"></ul>
|
||||
<small class="text-muted">
|
||||
These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding.
|
||||
</small>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(testBox);
|
||||
|
||||
const spinner = testBox.querySelector('#ooTestSpinner');
|
||||
const out = testBox.querySelector('#ooTestResults');
|
||||
|
||||
function ooRow(label, status, detail = '') {
|
||||
const li = document.createElement('li');
|
||||
li.style.margin = '6px 0';
|
||||
const icon = status === 'ok' ? '✅' : status === 'warn' ? '⚠️' : '❌';
|
||||
li.innerHTML =
|
||||
`<span style="min-width:1.2em;display:inline-block">${icon}</span>` +
|
||||
` <strong>${label}</strong>` +
|
||||
(detail ? ` — <span>${detail}</span>` : '');
|
||||
return li;
|
||||
}
|
||||
|
||||
function ooClear() {
|
||||
while (out.firstChild) out.removeChild(out.firstChild);
|
||||
}
|
||||
|
||||
async function runOnlyOfficeTests() {
|
||||
const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim();
|
||||
|
||||
spinner.style.display = 'inline';
|
||||
ooClear();
|
||||
|
||||
// 1) FileRise status
|
||||
let statusOk = false;
|
||||
try {
|
||||
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
|
||||
const statusJson = await r.json().catch(() => ({}));
|
||||
if (r.ok) {
|
||||
if (statusJson.enabled) {
|
||||
out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready'));
|
||||
statusOk = true;
|
||||
} else {
|
||||
out.appendChild(ooRow('FileRise status', 'warn', 'Disabled — check JWT Secret and Document Server Origin'));
|
||||
}
|
||||
} else {
|
||||
out.appendChild(ooRow('FileRise status', 'fail', `HTTP ${r.status}`));
|
||||
}
|
||||
} catch (e) {
|
||||
out.appendChild(ooRow('FileRise status', 'fail', (e && e.message) || 'Network error'));
|
||||
}
|
||||
|
||||
// 2) Secret presence (fresh read)
|
||||
try {
|
||||
const cfg = await fetch('/api/admin/getConfig.php', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
}).then(r => r.json());
|
||||
const hasSecret = !!(cfg.onlyoffice && cfg.onlyoffice.hasJwtSecret);
|
||||
out.appendChild(
|
||||
ooRow(
|
||||
'JWT secret saved',
|
||||
hasSecret ? 'ok' : 'fail',
|
||||
hasSecret ? 'Present' : 'Missing'
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
out.appendChild(ooRow('JWT secret saved', 'warn', 'Could not verify'));
|
||||
}
|
||||
|
||||
// 3) Callback reachable
|
||||
try {
|
||||
const r = await fetch('/api/onlyoffice/callback.php?ping=1', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (r.ok) out.appendChild(ooRow('Callback endpoint', 'ok', 'Reachable'));
|
||||
else out.appendChild(ooRow('Callback endpoint', 'fail', `HTTP ${r.status}`));
|
||||
} catch {
|
||||
out.appendChild(ooRow('Callback endpoint', 'fail', 'Network error'));
|
||||
}
|
||||
|
||||
// Basic sanity on origin
|
||||
if (!/^https?:\/\//i.test(docsOrigin)) {
|
||||
out.appendChild(
|
||||
ooRow(
|
||||
'Document Server Origin',
|
||||
'fail',
|
||||
'Enter a valid http(s) origin (e.g., https://docs.example.com)'
|
||||
)
|
||||
);
|
||||
spinner.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// 4a) api.js
|
||||
const sRes = await ooProbeScript(docsOrigin);
|
||||
out.appendChild(
|
||||
ooRow(
|
||||
'Load api.js',
|
||||
sRes.ok ? 'ok' : 'fail',
|
||||
sRes.ok ? 'Loaded' : 'Blocked (check CSP script-src and origin)'
|
||||
)
|
||||
);
|
||||
|
||||
// 4b) iframe
|
||||
const fRes = await ooProbeFrame(docsOrigin);
|
||||
out.appendChild(
|
||||
ooRow(
|
||||
'Embed DS iframe',
|
||||
fRes.ok ? 'ok' : 'fail',
|
||||
fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)'
|
||||
)
|
||||
);
|
||||
|
||||
if (!statusOk || !sRes.ok || !fRes.ok) {
|
||||
const tip = document.createElement('li');
|
||||
tip.style.marginTop = '8px';
|
||||
tip.innerHTML =
|
||||
'💡 <em>Tip:</em> Use the CSP helper below to include your Document Server in ' +
|
||||
'<code>script-src</code>, <code>connect-src</code>, and <code>frame-src</code>.';
|
||||
out.appendChild(tip);
|
||||
}
|
||||
|
||||
spinner.style.display = 'none';
|
||||
}
|
||||
|
||||
testBox.querySelector('#ooTestBtn')?.addEventListener('click', runOnlyOfficeTests);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSP helper card (Apache + Nginx snippets)
|
||||
*/
|
||||
function attachOnlyOfficeCspHelper(container) {
|
||||
const cspHelp = document.createElement('div');
|
||||
cspHelp.className = 'alert alert-info';
|
||||
cspHelp.style.marginTop = '12px';
|
||||
cspHelp.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||||
<strong>Content-Security-Policy help</strong>
|
||||
<button type="button" id="copyOoCsp" class="btn btn-sm btn-outline-secondary">Copy</button>
|
||||
<button type="button" id="selectOoCsp" class="btn btn-sm btn-outline-secondary">Select</button>
|
||||
</div>
|
||||
<div class="form-text" style="margin-bottom:8px;">
|
||||
Add/replace this line in <code>public/.htaccess</code> (Apache). It allows loading ONLYOFFICE's <code>api.js</code>,
|
||||
embedding the editor iframe, and letting the script make XHR to your Document Server.
|
||||
</div>
|
||||
<pre id="ooCspSnippet" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7;"></pre>
|
||||
<div class="form-text" style="margin-top:8px;">
|
||||
If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead.
|
||||
Also note: if your site is <code>https://</code>, your ONLYOFFICE server must be <code>https://</code> too,
|
||||
otherwise the browser will block it as mixed content.
|
||||
</div>
|
||||
<details style="margin-top:8px;">
|
||||
<summary>Nginx equivalent</summary>
|
||||
<pre id="ooCspSnippetNginx" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7; margin-top:6px;"></pre>
|
||||
</details>
|
||||
`;
|
||||
container.appendChild(cspHelp);
|
||||
|
||||
const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=";
|
||||
|
||||
function buildCspApache(originRaw) {
|
||||
const o = (originRaw || 'https://your-onlyoffice-server.example.com').replace(/\/+$/, '');
|
||||
const api = `${o}/web-apps/apps/api/documents/api.js`;
|
||||
return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
|
||||
}
|
||||
|
||||
function buildCspNginx(originRaw) {
|
||||
const o = (originRaw || 'https://your-onlyoffice-server.example.com').replace(/\/+$/, '');
|
||||
const api = `${o}/web-apps/apps/api/documents/api.js`;
|
||||
return `add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}" always;`;
|
||||
}
|
||||
|
||||
const ooDocsInput = document.getElementById('ooDocsOrigin');
|
||||
const cspPre = document.getElementById('ooCspSnippet');
|
||||
const cspPreNgx = document.getElementById('ooCspSnippetNginx');
|
||||
|
||||
function refreshCsp() {
|
||||
const raw = (ooDocsInput?.value || '').trim();
|
||||
const base = getTrustedDocsOrigin(raw) || raw;
|
||||
cspPre.textContent = buildCspApache(base);
|
||||
cspPreNgx.textContent = buildCspNginx(base);
|
||||
}
|
||||
|
||||
ooDocsInput?.addEventListener('input', refreshCsp);
|
||||
refreshCsp();
|
||||
|
||||
document.getElementById('copyOoCsp')?.addEventListener('click', async () => {
|
||||
const txt = (cspPre.textContent || '').trim();
|
||||
const ok = await copyToClipboard(txt);
|
||||
if (ok) {
|
||||
showToast('CSP line copied.');
|
||||
} else {
|
||||
try { selectElementContents(cspPre); } catch { /* ignore */ }
|
||||
const reason = window.isSecureContext ? '' : ' (page is not HTTPS or localhost)';
|
||||
showToast('Copy failed' + reason + '. Press Ctrl/Cmd+C to copy.');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('selectOoCsp')?.addEventListener('click', () => {
|
||||
try {
|
||||
selectElementContents(cspPre);
|
||||
showToast('Selected — press Ctrl/Cmd+C');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Public: build + wire ONLYOFFICE admin section
|
||||
*/
|
||||
export function initOnlyOfficeUI({ config }) {
|
||||
const sec = document.getElementById('onlyofficeContent');
|
||||
if (!sec) return;
|
||||
|
||||
const onlyCfg = config.onlyoffice || {};
|
||||
const hasOOSecret = !!onlyCfg.hasJwtSecret;
|
||||
window.__HAS_OO_SECRET = hasOOSecret;
|
||||
|
||||
// Base content
|
||||
sec.innerHTML = `
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="ooEnabled" />
|
||||
<label for="ooEnabled">Enable ONLYOFFICE integration</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ooDocsOrigin">Document Server Origin:</label>
|
||||
<input type="url" id="ooDocsOrigin" class="form-control" placeholder="e.g. https://docs.example.com" />
|
||||
<small class="text-muted">
|
||||
Must be reachable by your browser (for api.js) and by FileRise (for callbacks). Avoid “localhost”.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
${renderMaskedInput({
|
||||
id: 'ooJwtSecret',
|
||||
label: 'JWT Secret',
|
||||
hasValue: hasOOSecret,
|
||||
isSecret: true
|
||||
})}
|
||||
`;
|
||||
|
||||
wireReplaceButtons(sec);
|
||||
|
||||
// Tests + CSP helper
|
||||
attachOnlyOfficeTests(sec);
|
||||
attachOnlyOfficeCspHelper(sec);
|
||||
|
||||
// Initial values
|
||||
const enabled = !!onlyCfg.enabled;
|
||||
const docsOrigin = onlyCfg.docsOrigin || '';
|
||||
|
||||
const enabledEl = document.getElementById('ooEnabled');
|
||||
const originEl = document.getElementById('ooDocsOrigin');
|
||||
|
||||
if (enabledEl) enabledEl.checked = enabled;
|
||||
if (originEl) originEl.value = docsOrigin;
|
||||
|
||||
// Locking (managed in config.php)
|
||||
const locked = !!onlyCfg.lockedByPhp;
|
||||
window.__OO_LOCKED = locked;
|
||||
if (locked) {
|
||||
sec.querySelectorAll('input,button').forEach(el => {
|
||||
el.disabled = true;
|
||||
});
|
||||
const note = document.createElement('div');
|
||||
note.className = 'form-text';
|
||||
note.style.marginTop = '6px';
|
||||
note.textContent = 'Managed by config.php — edit ONLYOFFICE_* constants there.';
|
||||
sec.appendChild(note);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public: inject ONLYOFFICE settings into payload (used in handleSave)
|
||||
*/
|
||||
export function collectOnlyOfficeSettingsForSave(payload) {
|
||||
const ooEnabledEl = document.getElementById('ooEnabled');
|
||||
const ooDocsOriginEl = document.getElementById('ooDocsOrigin');
|
||||
const ooSecretEl = document.getElementById('ooJwtSecret');
|
||||
|
||||
const onlyoffice = {
|
||||
enabled: !!(ooEnabledEl && ooEnabledEl.checked),
|
||||
docsOrigin: (ooDocsOriginEl && ooDocsOriginEl.value.trim()) || ''
|
||||
};
|
||||
|
||||
if (!window.__OO_LOCKED && ooSecretEl) {
|
||||
const val = ooSecretEl.value.trim();
|
||||
const hasSaved = !!window.__HAS_OO_SECRET;
|
||||
const shouldReplace = ooSecretEl.dataset.replace === '1' || !hasSaved;
|
||||
if (shouldReplace && val !== '') {
|
||||
onlyoffice.jwtSecret = val;
|
||||
}
|
||||
}
|
||||
|
||||
payload.onlyoffice = onlyoffice;
|
||||
return payload;
|
||||
}
|
||||
1765
public/js/adminPortals.js
Normal file
118
public/js/adminSponsor.js
Normal file
@@ -0,0 +1,118 @@
|
||||
// public/js/adminSponsor.js
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
|
||||
// Tiny "translate with fallback" helper, same as in adminPanel.js
|
||||
const tf = (key, fallback) => {
|
||||
const v = t(key);
|
||||
return (v && v !== key) ? v : fallback;
|
||||
};
|
||||
|
||||
const SPONSOR_GH = 'https://github.com/sponsors/error311';
|
||||
const SPONSOR_KOFI = 'https://ko-fi.com/error311';
|
||||
|
||||
/**
|
||||
* Initialize the Sponsor / Donations section inside the Admin Panel.
|
||||
* Safe to call multiple times; it no-ops after the first run.
|
||||
*/
|
||||
export function initAdminSponsorSection() {
|
||||
const container = document.getElementById('sponsorContent');
|
||||
if (!container) return;
|
||||
|
||||
// Avoid double-wiring if initAdminSponsorSection gets called again
|
||||
if (container.__sponsorInited) return;
|
||||
container.__sponsorInited = true;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="form-group" style="margin-bottom:12px;">
|
||||
<label for="sponsorGitHub">${tf("github_sponsors_url", "GitHub Sponsors URL")}:</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="url"
|
||||
id="sponsorGitHub"
|
||||
class="form-control"
|
||||
value="${SPONSOR_GH}"
|
||||
readonly
|
||||
data-ignore-dirty="1"
|
||||
/>
|
||||
<button type="button" id="copySponsorGitHub" class="btn btn-outline-primary">
|
||||
${tf("copy", "Copy")}
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-outline-secondary"
|
||||
id="openSponsorGitHub"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
${tf("open", "Open")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:12px;">
|
||||
<label for="sponsorKoFi">${tf("ko_fi_url", "Ko-fi URL")}:</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="url"
|
||||
id="sponsorKoFi"
|
||||
class="form-control"
|
||||
value="${SPONSOR_KOFI}"
|
||||
readonly
|
||||
data-ignore-dirty="1"
|
||||
/>
|
||||
<button type="button" id="copySponsorKoFi" class="btn btn-outline-primary">
|
||||
${tf("copy", "Copy")}
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-outline-secondary"
|
||||
id="openSponsorKoFi"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
${tf("open", "Open")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<small class="text-muted">
|
||||
${tf("sponsor_note_fixed", "Please consider supporting ongoing development.")}
|
||||
</small>
|
||||
`;
|
||||
|
||||
const ghInput = document.getElementById('sponsorGitHub');
|
||||
const kfInput = document.getElementById('sponsorKoFi');
|
||||
const copyGhBtn = document.getElementById('copySponsorGitHub');
|
||||
const copyKfBtn = document.getElementById('copySponsorKoFi');
|
||||
const openGh = document.getElementById('openSponsorGitHub');
|
||||
const openKf = document.getElementById('openSponsorKoFi');
|
||||
|
||||
if (openGh) openGh.href = SPONSOR_GH;
|
||||
if (openKf) openKf.href = SPONSOR_KOFI;
|
||||
|
||||
async function copyToClipboardSafe(text) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
ta.remove();
|
||||
}
|
||||
showToast(tf("copied", "Copied!"));
|
||||
} catch {
|
||||
showToast(tf("copy_failed", "Could not copy. Please copy manually."));
|
||||
}
|
||||
}
|
||||
|
||||
if (copyGhBtn && ghInput) {
|
||||
copyGhBtn.addEventListener('click', () => copyToClipboardSafe(ghInput.value));
|
||||
}
|
||||
if (copyKfBtn && kfInput) {
|
||||
copyKfBtn.addEventListener('click', () => copyToClipboardSafe(kfInput.value));
|
||||
}
|
||||
}
|
||||
1684
public/js/adminStorage.js
Normal 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)
|
||||
========================= */
|
||||
|
||||
@@ -34,18 +34,19 @@ window.currentOIDCConfig = currentOIDCConfig;
|
||||
|
||||
|
||||
(function installToastFilter() {
|
||||
const isDemoHost = location.hostname.toLowerCase() === 'demo.filerise.net';
|
||||
|
||||
window.__FR_TOAST_FILTER__ = function (msgKeyOrText) {
|
||||
const isDemoMode = !!window.__FR_DEMO__;
|
||||
|
||||
// Suppress the nag while doing TOTP step-up
|
||||
if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' ||
|
||||
/please log in/i.test(String(msgKeyOrText)))) {
|
||||
return null; // suppress
|
||||
}
|
||||
|
||||
// Demo host
|
||||
if (isDemoHost && (msgKeyOrText === 'please_log_in_to_continue' ||
|
||||
/please log in/i.test(String(msgKeyOrText)))) {
|
||||
// Demo mode: swap login prompt for demo creds
|
||||
if (isDemoMode &&
|
||||
(msgKeyOrText === 'please_log_in_to_continue' ||
|
||||
/please log in/i.test(String(msgKeyOrText)))) {
|
||||
return "Demo site — use:\nUsername: demo\nPassword: demo";
|
||||
}
|
||||
|
||||
@@ -81,14 +82,16 @@ window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_requi
|
||||
// override showToast to suppress the "Please log in to continue." toast during TOTP
|
||||
|
||||
function showToast(msgKeyOrText, type) {
|
||||
const isDemoHost = window.location.hostname.toLowerCase() === "demo.filerise.net";
|
||||
const isDemoMode = !!window.__FR_DEMO__;
|
||||
|
||||
// If it's the pre-login prompt and we're on the demo site, show demo creds instead.
|
||||
if (isDemoHost) {
|
||||
// For the pre-login prompt in demo mode, show demo creds instead
|
||||
if (isDemoMode &&
|
||||
(msgKeyOrText === "please_log_in_to_continue" ||
|
||||
/please log in/i.test(String(msgKeyOrText)))) {
|
||||
return originalShowToast("Demo site — use: \nUsername: demo\nPassword: demo", 12000);
|
||||
}
|
||||
|
||||
// Don’t nag during pending TOTP, as you already had
|
||||
// Don’t nag during pending TOTP
|
||||
if (window.pendingTOTP && msgKeyOrText === "please_log_in_to_continue") {
|
||||
return;
|
||||
}
|
||||
@@ -97,11 +100,10 @@ function showToast(msgKeyOrText, type) {
|
||||
let msg = msgKeyOrText;
|
||||
try {
|
||||
const translated = t(msgKeyOrText);
|
||||
// If t() changed it or it's a key-like string, use the translation
|
||||
if (typeof translated === "string" && translated !== msgKeyOrText) {
|
||||
msg = translated;
|
||||
}
|
||||
} catch { /* if t() isn’t available here, just use the original */ }
|
||||
} catch { }
|
||||
|
||||
return originalShowToast(msg);
|
||||
}
|
||||
@@ -351,26 +353,8 @@ export async function updateAuthenticatedUI(data) {
|
||||
if (r) r.style.display = "none";
|
||||
}
|
||||
|
||||
// b) admin panel button only on demo.filerise.net
|
||||
if (data.isAdmin && window.location.hostname === "demo.filerise.net") {
|
||||
let a = document.getElementById("adminPanelBtn");
|
||||
if (!a) {
|
||||
a = document.createElement("button");
|
||||
a.id = "adminPanelBtn";
|
||||
a.classList.add("btn", "btn-info");
|
||||
a.setAttribute("data-i18n-title", "admin_panel");
|
||||
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
||||
insertAfter(a, document.getElementById("restoreFilesBtn"));
|
||||
a.addEventListener("click", openAdminPanel);
|
||||
}
|
||||
a.style.display = "block";
|
||||
} else {
|
||||
const a = document.getElementById("adminPanelBtn");
|
||||
if (a) a.style.display = "none";
|
||||
}
|
||||
|
||||
// c) user dropdown on non-demo
|
||||
if (window.location.hostname !== "demo.filerise.net") {
|
||||
{
|
||||
let dd = document.getElementById("userDropdown");
|
||||
|
||||
// choose icon *or* img
|
||||
@@ -866,6 +850,10 @@ function initAuth() {
|
||||
});
|
||||
document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal);
|
||||
document.getElementById("changePasswordBtn").addEventListener("click", function () {
|
||||
if (window.__FR_DEMO__) {
|
||||
showToast("Password changes are disabled on the public demo.");
|
||||
return;
|
||||
}
|
||||
document.getElementById("changePasswordModal").style.display = "block";
|
||||
document.getElementById("oldPassword").focus();
|
||||
});
|
||||
@@ -873,6 +861,10 @@ function initAuth() {
|
||||
document.getElementById("changePasswordModal").style.display = "none";
|
||||
});
|
||||
document.getElementById("saveNewPasswordBtn").addEventListener("click", function () {
|
||||
if (window.__FR_DEMO__) {
|
||||
showToast("Password changes are disabled on the public demo.");
|
||||
return;
|
||||
}
|
||||
const oldPassword = document.getElementById("oldPassword").value.trim();
|
||||
const newPassword = document.getElementById("newPassword").value.trim();
|
||||
const confirmPassword = document.getElementById("confirmPassword").value.trim();
|
||||
|
||||
@@ -10,6 +10,15 @@ export function setLastLoginData(data) {
|
||||
//window.__lastLoginData = data;
|
||||
}
|
||||
|
||||
function isHoverPreviewDisabled() {
|
||||
if (window.disableHoverPreview === true) return true;
|
||||
try {
|
||||
return localStorage.getItem('disableHoverPreview') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function openTOTPLoginModal() {
|
||||
let totpLoginModal = document.getElementById("totpLoginModal");
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
@@ -195,7 +204,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 +360,152 @@ 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);
|
||||
// re‐load the entire file list (and strip) in one go:
|
||||
loadFileList(window.currentFolder);
|
||||
// 4) Disable hover preview
|
||||
const hoverLabel = document.createElement('label');
|
||||
hoverLabel.style.cursor = 'pointer';
|
||||
hoverLabel.style.display = 'block';
|
||||
hoverLabel.style.marginTop = '4px';
|
||||
|
||||
const hoverCb = document.createElement('input');
|
||||
hoverCb.type = 'checkbox';
|
||||
hoverCb.id = 'disableHoverPreview';
|
||||
hoverCb.style.verticalAlign = 'middle';
|
||||
|
||||
{
|
||||
const storedHover = localStorage.getItem('disableHoverPreview');
|
||||
hoverCb.checked = storedHover === 'true';
|
||||
// also mirror into a global flag for runtime checks
|
||||
window.disableHoverPreview = hoverCb.checked;
|
||||
}
|
||||
|
||||
hoverLabel.appendChild(hoverCb);
|
||||
hoverLabel.append(
|
||||
` ${t('disable_hover_preview') || 'Disable file hover preview'}`
|
||||
);
|
||||
dispFs.appendChild(hoverLabel);
|
||||
|
||||
// Handler: toggle hover preview
|
||||
hoverCb.addEventListener('change', () => {
|
||||
const disabled = hoverCb.checked;
|
||||
localStorage.setItem('disableHoverPreview', disabled ? 'true' : 'false');
|
||||
window.disableHoverPreview = disabled;
|
||||
|
||||
// Hide any currently-visible preview right away
|
||||
const preview = document.getElementById('hoverPreview');
|
||||
if (preview) {
|
||||
preview.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
inlineCb.addEventListener('change', () => {
|
||||
window.showInlineFolders = inlineCb.checked;
|
||||
localStorage.setItem('showInlineFolders', inlineCb.checked);
|
||||
if (typeof window.loadFileList === 'function') {
|
||||
window.loadFileList(window.currentFolder || 'root');
|
||||
}
|
||||
});
|
||||
|
||||
// wire up image‐input change
|
||||
@@ -425,6 +556,25 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
const hoverCb = modal.querySelector('#disableHoverPreview');
|
||||
if (hoverCb) {
|
||||
const storedHover = localStorage.getItem('disableHoverPreview');
|
||||
hoverCb.checked = storedHover === 'true';
|
||||
window.disableHoverPreview = hoverCb.checked;
|
||||
}
|
||||
|
||||
// show
|
||||
|
||||
@@ -160,12 +160,12 @@ 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>${t("actions")}</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="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 data-column="actions" class="actions-col">${t("actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
`;
|
||||
@@ -175,84 +175,32 @@ export function buildFileTableRow(file, folderPath) {
|
||||
const safeFileName = escapeHTML(file.name);
|
||||
const safeModified = escapeHTML(file.modified);
|
||||
const safeUploaded = escapeHTML(file.uploaded);
|
||||
const safeSize = escapeHTML(file.size);
|
||||
const safeSize = escapeHTML(file.size);
|
||||
const safeUploader = escapeHTML(file.uploader || "Unknown");
|
||||
|
||||
let previewButton = "";
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i.test(file.name)) {
|
||||
let previewIcon = "";
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic)$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">image</i>`;
|
||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">videocam</i>`;
|
||||
} else if (/\.pdf$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">picture_as_pdf</i>`;
|
||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
||||
}
|
||||
previewButton = `<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info preview-btn"
|
||||
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
||||
data-preview-name="${safeFileName}"
|
||||
title="${t('preview')}">
|
||||
${previewIcon}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<tr class="clickable-row">
|
||||
<td>
|
||||
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
|
||||
</td>
|
||||
<td class="file-name-cell">${safeFileName}</td>
|
||||
<td class="hide-small nowrap">${safeModified}</td>
|
||||
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
|
||||
<td class="hide-small nowrap">${safeSize}</td>
|
||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="File actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-success download-btn"
|
||||
data-download-name="${file.name}"
|
||||
data-download-folder="${file.folder || 'root'}"
|
||||
title="${t('download')}">
|
||||
<i class="material-icons">file_download</i>
|
||||
<tr class="clickable-row" data-file-name="${safeFileName}">
|
||||
<td>
|
||||
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
|
||||
</td>
|
||||
<td class="file-name-cell name-cell">
|
||||
${safeFileName}
|
||||
</td>
|
||||
<td class="hide-small nowrap">${safeModified}</td>
|
||||
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
|
||||
<td class="hide-small nowrap size-cell">${safeSize}</td>
|
||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||
<td class="actions-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link btn-actions-ellipsis"
|
||||
title="${t("more_actions")}"
|
||||
>
|
||||
<span class="material-icons">more_vert</span>
|
||||
</button>
|
||||
|
||||
${file.editable ? `
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary edit-btn"
|
||||
data-edit-name="${file.name}"
|
||||
data-edit-folder="${file.folder || 'root'}"
|
||||
title="${t('edit')}">
|
||||
<i class="material-icons">edit</i>
|
||||
</button>` : ""}
|
||||
|
||||
${previewButton}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-warning rename-btn"
|
||||
data-rename-name="${file.name}"
|
||||
data-rename-folder="${file.folder || 'root'}"
|
||||
title="${t('rename')}">
|
||||
<i class="material-icons">drive_file_rename_outline</i>
|
||||
</button>
|
||||
<!-- share -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm share-btn ms-1"
|
||||
data-file="${safeFileName}"
|
||||
title="${t('share')}">
|
||||
<i class="material-icons">share</i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildBottomControls(itemsPerPageSetting) {
|
||||
|
||||
@@ -72,6 +72,48 @@ 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);
|
||||
|
||||
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),
|
||||
|
||||
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 || '',
|
||||
|
||||
// ✨ make the ghost crisper
|
||||
overflow: 'hidden',
|
||||
willChange: 'transform, opacity',
|
||||
backfaceVisibility: 'hidden'
|
||||
});
|
||||
|
||||
// Subtle: de-emphasize inner text so it doesn’t look “smeared”
|
||||
const ghBody = ghost.querySelector('.card-body');
|
||||
if (ghBody) ghBody.style.opacity = '0.6';
|
||||
|
||||
return ghost;
|
||||
}
|
||||
|
||||
// -------------------- header (icon+modal) --------------------
|
||||
function saveHeaderOrder() {
|
||||
const host = getHeaderDropArea();
|
||||
@@ -98,7 +140,19 @@ function insertCardInHeader(card) {
|
||||
if (!hidden) {
|
||||
hidden = document.createElement('div');
|
||||
hidden.id = 'hiddenCardsContainer';
|
||||
hidden.style.display = 'none';
|
||||
|
||||
// Park cards off–screen 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 +231,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 +384,227 @@ 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 };
|
||||
});
|
||||
|
||||
// Make sure header dock is visible so icons are laid out
|
||||
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: 0.95 });
|
||||
ghost.id = card.id + '-ghost-collapse';
|
||||
ghost.classList.add('card-collapse-ghost');
|
||||
ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear';
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
ghosts.push({ ghost, from: rect, to: iconRect });
|
||||
});
|
||||
|
||||
if (!ghosts.length) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kick off motion on next frame
|
||||
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.35, Math.min(0.6, rawScale * 0.9));
|
||||
|
||||
// ✨ more readable: clear slide + shrink, but don’t fully vanish mid-flight
|
||||
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
|
||||
ghost.style.opacity = '0.35';
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} });
|
||||
done();
|
||||
}, 430); // a bit over the 0.4s transition
|
||||
}
|
||||
|
||||
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;
|
||||
const START_OFFSET_Y = 32; // a touch closer to header
|
||||
const DEST_EXTRA_Y = 120;
|
||||
|
||||
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;
|
||||
|
||||
const fromCx = iconRect.left + iconRect.width / 2;
|
||||
const fromCy = iconRect.bottom + START_OFFSET_Y;
|
||||
|
||||
let toCx = zoneRect.left + zoneRect.width / 2;
|
||||
let toCy = zoneRect.top + Math.min(zoneRect.height / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y);
|
||||
|
||||
if (zoneId === ZONES.SIDEBAR) {
|
||||
if (card.id === 'uploadCard') {
|
||||
toCy -= 48;
|
||||
} else if (card.id === 'folderManagementCard') {
|
||||
toCy += 48;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const startTop = Math.max(SAFE_TOP, fromCy - targetHeight / 2);
|
||||
|
||||
const ghostRect = {
|
||||
left: fromCx - targetWidth / 2,
|
||||
top: startTop,
|
||||
width: targetWidth,
|
||||
height: targetHeight
|
||||
};
|
||||
|
||||
const ghost = createCardGhost(card, ghostRect, { scale: 0.75, opacity: 0.25 });
|
||||
ghost.id = card.id + '-ghost-expand';
|
||||
ghost.classList.add('card-expand-ghost');
|
||||
|
||||
ghost.style.transform = 'translate(0,0) scale(0.75)';
|
||||
ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear';
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
ghosts.push({
|
||||
ghost,
|
||||
from: { cx: fromCx, cy: fromCy },
|
||||
to: { cx: toCx, cy: toCy },
|
||||
zoneId
|
||||
});
|
||||
});
|
||||
|
||||
if (!ghosts.length) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
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';
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
ghosts.forEach(({ ghost }) => {
|
||||
try { ghost.remove(); } catch {}
|
||||
});
|
||||
done();
|
||||
}, 430);
|
||||
}
|
||||
|
||||
// -------------------- 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// fileDragDrop.js
|
||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { loadFileList, cancelHoverPreview } from './fileListView.js?v={{APP_QVER}}';
|
||||
|
||||
/* ---------------- helpers ---------------- */
|
||||
function getRowEl(el) {
|
||||
@@ -54,6 +54,7 @@ function makeDragImage(labelText, iconName = 'insert_drive_file') {
|
||||
|
||||
/* ---------------- drag start (rows/cards) ---------------- */
|
||||
export function fileDragStartHandler(event) {
|
||||
try { cancelHoverPreview(); } catch {}
|
||||
const row = getRowEl(event.currentTarget);
|
||||
if (!row) return;
|
||||
|
||||
|
||||
@@ -9,6 +9,56 @@ export function buildPreviewUrl(folder, name) {
|
||||
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
|
||||
}
|
||||
|
||||
// New: build a download URL (attachment)
|
||||
export function buildDownloadUrl(folder, name) {
|
||||
const f = (!folder || folder === '') ? 'root' : String(folder);
|
||||
const params = new URLSearchParams({
|
||||
folder: f,
|
||||
file: name,
|
||||
inline: '0',
|
||||
t: String(Date.now())
|
||||
});
|
||||
return `/api/file/download.php?${params.toString()}`;
|
||||
}
|
||||
|
||||
const MEDIA_VOLUME_KEY = 'frMediaVolume';
|
||||
const MEDIA_MUTED_KEY = 'frMediaMuted';
|
||||
|
||||
function loadSavedMediaVolume(el) {
|
||||
if (!el) return;
|
||||
try {
|
||||
const v = localStorage.getItem(MEDIA_VOLUME_KEY);
|
||||
if (v !== null) {
|
||||
const vol = parseFloat(v);
|
||||
if (!Number.isNaN(vol)) {
|
||||
el.volume = Math.max(0, Math.min(1, vol));
|
||||
}
|
||||
}
|
||||
const m = localStorage.getItem(MEDIA_MUTED_KEY);
|
||||
if (m !== null) {
|
||||
el.muted = (m === '1');
|
||||
}
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function attachVolumePersistence(el) {
|
||||
if (!el) return;
|
||||
try {
|
||||
el.addEventListener('volumechange', () => {
|
||||
try {
|
||||
localStorage.setItem(MEDIA_VOLUME_KEY, String(el.volume));
|
||||
localStorage.setItem(MEDIA_MUTED_KEY, el.muted ? '1' : '0');
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------- Share modal (existing) -------------------------------- */
|
||||
export function openShareModal(file, folder) {
|
||||
const existing = document.getElementById("shareModal");
|
||||
@@ -120,7 +170,12 @@ export function openShareModal(file, folder) {
|
||||
}
|
||||
|
||||
/* -------------------------------- Media modal viewer -------------------------------- */
|
||||
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
|
||||
// Images that are safe to inline in <img> tags:
|
||||
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|ico)$/i;
|
||||
|
||||
// SVG handled separately so we *don’t* inline it
|
||||
const SVG_RE = /\.svg$/i;
|
||||
|
||||
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
|
||||
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
|
||||
const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
|
||||
@@ -239,7 +294,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 +346,67 @@ 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New: Download icon that uses current file name
|
||||
function makeDownloadButton(folder, getName) {
|
||||
const btn = makeTopIcon('download', t('download') || 'Download');
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const nm = getName && getName();
|
||||
if (!nm) return;
|
||||
|
||||
const url = buildDownloadUrl(folder, nm);
|
||||
|
||||
// Use a temporary <a> with download attribute for nicer behavior
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = nm;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
// Topbar icon (theme-aware) used for image tools + video actions
|
||||
@@ -374,11 +498,39 @@ export function previewFile(fileUrl, fileName) {
|
||||
const folder = window.currentFolder || 'root';
|
||||
const name = fileName;
|
||||
const lower = (name || '').toLowerCase();
|
||||
const isSvg = SVG_RE.test(lower);
|
||||
const isImage = IMG_RE.test(lower);
|
||||
const isVideo = VID_RE.test(lower);
|
||||
const isAudio = AUD_RE.test(lower);
|
||||
|
||||
// Base preview URL from the link we clicked
|
||||
const baseUrl = fileUrl;
|
||||
|
||||
// Use the same preview endpoint, just swap the "file" param.
|
||||
function siblingPreviewUrl(newName) {
|
||||
try {
|
||||
const u = new URL(baseUrl, window.location.origin);
|
||||
u.searchParams.set('file', newName);
|
||||
// cache-bust so we don’t get stale frames
|
||||
u.searchParams.set('t', String(Date.now()));
|
||||
return u.toString();
|
||||
} catch {
|
||||
// Fallback: go through generic download/inline endpoint
|
||||
return buildPreviewUrl(folder, newName);
|
||||
}
|
||||
}
|
||||
|
||||
setTitle(overlay, name);
|
||||
if (isSvg) {
|
||||
const downloadBtn = makeDownloadButton(folder, () => name);
|
||||
actionWrap.appendChild(downloadBtn);
|
||||
|
||||
container.textContent =
|
||||
t("svg_preview_disabled") ||
|
||||
"SVG preview is disabled for security. Use Download to view this file.";
|
||||
overlay.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
/* -------------------- IMAGES -------------------- */
|
||||
if (isImage) {
|
||||
@@ -391,12 +543,17 @@ export function previewFile(fileUrl, fileName) {
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
container.appendChild(img);
|
||||
|
||||
|
||||
let currentName = name;
|
||||
|
||||
// topbar-aligned, theme-aware icons
|
||||
const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
|
||||
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
|
||||
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
|
||||
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
|
||||
const downloadBtn = makeDownloadButton(folder, () => currentName);
|
||||
|
||||
actionWrap.appendChild(downloadBtn);
|
||||
actionWrap.appendChild(zoomInBtn);
|
||||
actionWrap.appendChild(zoomOutBtn);
|
||||
actionWrap.appendChild(rotateLeft);
|
||||
@@ -428,21 +585,22 @@ export function previewFile(fileUrl, fileName) {
|
||||
});
|
||||
|
||||
const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name));
|
||||
overlay.mediaType = 'image';
|
||||
overlay.mediaList = images;
|
||||
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, images.length > 1, images.length > 1);
|
||||
overlay.mediaType = 'image';
|
||||
overlay.mediaList = images;
|
||||
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, images.length > 1, images.length > 1);
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const newFile = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setTitle(overlay, newFile);
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
img.style.transform = 'scale(1) rotate(0deg)';
|
||||
img.src = buildPreviewUrl(folder, newFile);
|
||||
};
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const newFile = overlay.mediaList[overlay.mediaIndex].name;
|
||||
currentName = newFile; // keep download button pointing to the right file
|
||||
setTitle(overlay, newFile);
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
img.style.transform = 'scale(1) rotate(0deg)';
|
||||
img.src = siblingPreviewUrl(newFile); // <-- changed
|
||||
};
|
||||
|
||||
if (images.length > 1) {
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
@@ -469,179 +627,226 @@ export function previewFile(fileUrl, fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* -------------------- VIDEOS -------------------- */
|
||||
if (isVideo) {
|
||||
let video = document.createElement("video"); // let so we can rebind
|
||||
video.controls = true;
|
||||
video.style.maxWidth = "88vw";
|
||||
video.style.maxHeight = "88vh";
|
||||
video.style.objectFit = "contain";
|
||||
container.appendChild(video);
|
||||
/* -------------------- VIDEOS -------------------- */
|
||||
if (isVideo) {
|
||||
let video = document.createElement("video");
|
||||
video.controls = true;
|
||||
video.preload = 'auto'; // hint browser to start fetching quickly
|
||||
video.style.maxWidth = "88vw";
|
||||
video.style.maxHeight = "88vh";
|
||||
video.style.objectFit = "contain";
|
||||
container.appendChild(video);
|
||||
|
||||
// Top-right action icons (Material icons, theme-aware)
|
||||
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||
actionWrap.appendChild(markBtnIcon);
|
||||
actionWrap.appendChild(clearBtnIcon);
|
||||
// Apply last-used volume/mute, and persist future changes
|
||||
loadSavedMediaVolume(video);
|
||||
attachVolumePersistence(video);
|
||||
|
||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||
overlay.mediaType = 'video';
|
||||
overlay.mediaList = videos;
|
||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||
// Top-right action icons (Material icons, theme-aware)
|
||||
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||
|
||||
const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); };
|
||||
// Track which file is currently active
|
||||
let currentName = name;
|
||||
|
||||
const SAVE_INTERVAL_MS = 5000;
|
||||
let lastSaveAt = 0;
|
||||
let pending = false;
|
||||
// Use the URL we were passed in (old behavior) for the *first* video,
|
||||
// fall back to API URL if for some reason it's empty.
|
||||
const initialUrl = fileUrl && fileUrl.trim()
|
||||
? fileUrl
|
||||
: buildPreviewUrl(folder, name);
|
||||
|
||||
async function getProgress(nm) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return data && data.state ? data.state : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||
try {
|
||||
pending = true;
|
||||
const res = await fetch("/api/media/updateProgress.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||
});
|
||||
const data = await res.json();
|
||||
pending = false;
|
||||
return data;
|
||||
} catch (e) { pending = false; console.error(e); return null; }
|
||||
}
|
||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||
const downloadBtn = makeDownloadButton(folder, () => currentName);
|
||||
|
||||
function renderStatus(state) {
|
||||
if (!statusChip) return;
|
||||
// Completed
|
||||
if (state && state.completed) {
|
||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||
statusChip.style.display = 'inline-block';
|
||||
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||
statusChip.style.color = '#22c55e';
|
||||
markBtnIcon.style.display = 'none';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
// In progress
|
||||
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
statusChip.textContent = `${pct}%`;
|
||||
statusChip.style.display = 'inline-block';
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
|
||||
statusChip.style.color = ORANGE_HEX;
|
||||
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
|
||||
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
// No progress
|
||||
statusChip.style.display = 'none';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = 'none';
|
||||
}
|
||||
// Order: Download | Mark | Reset
|
||||
actionWrap.appendChild(downloadBtn);
|
||||
actionWrap.appendChild(markBtnIcon);
|
||||
actionWrap.appendChild(clearBtnIcon);
|
||||
|
||||
function bindVideoEvents(nm) {
|
||||
const nv = video.cloneNode(true);
|
||||
video.replaceWith(nv);
|
||||
video = nv;
|
||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||
overlay.mediaType = 'video';
|
||||
overlay.mediaList = videos;
|
||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||
|
||||
video.addEventListener("loadedmetadata", async () => {
|
||||
try {
|
||||
const state = await getProgress(nm);
|
||||
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
||||
video.currentTime = state.seconds;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
||||
} else {
|
||||
const ls = localStorage.getItem(lsKey(nm));
|
||||
if (ls) video.currentTime = parseFloat(ls);
|
||||
}
|
||||
renderStatus(state || null);
|
||||
} catch {
|
||||
renderStatus(null);
|
||||
}
|
||||
});
|
||||
// Helper: set src for a given video name
|
||||
const setVideoSrc = (nm) => {
|
||||
currentName = nm;
|
||||
|
||||
video.addEventListener("timeupdate", async () => {
|
||||
const now = Date.now();
|
||||
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
|
||||
lastSaveAt = now;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
sendProgress({ nm, seconds, duration });
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
|
||||
renderStatus({ seconds, duration, completed: false });
|
||||
});
|
||||
// For the current file, reuse the original working URL.
|
||||
// For other files (next/prev), go through the API.
|
||||
const url = (nm === name) ? initialUrl : buildPreviewUrl(folder, nm);
|
||||
|
||||
video.addEventListener("ended", async () => {
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
});
|
||||
video.src = url;
|
||||
video.src = siblingPreviewUrl(nm);
|
||||
setTitle(overlay, nm);
|
||||
};
|
||||
|
||||
markBtnIcon.onclick = async () => {
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
};
|
||||
clearBtnIcon.onclick = async () => {
|
||||
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("progress_cleared") || "Progress cleared");
|
||||
setFileWatchedBadge(nm, false);
|
||||
renderStatus(null);
|
||||
};
|
||||
}
|
||||
const SAVE_INTERVAL_MS = 5000;
|
||||
let lastSaveAt = 0;
|
||||
let pending = false;
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const nm = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setVideoSrc(nm);
|
||||
bindVideoEvents(nm);
|
||||
};
|
||||
|
||||
if (videos.length > 1) {
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||
const onKey = (e) => {
|
||||
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
|
||||
if (e.key === "ArrowLeft") navigate(-1);
|
||||
if (e.key === "ArrowRight") navigate(+1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
overlay._onKey = onKey;
|
||||
}
|
||||
|
||||
setVideoSrc(name);
|
||||
renderStatus(null);
|
||||
bindVideoEvents(name);
|
||||
overlay.style.display = "flex";
|
||||
return;
|
||||
async function getProgress(nm) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return data && data.state ? data.state : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||
try {
|
||||
pending = true;
|
||||
const res = await fetch("/api/media/updateProgress.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||
});
|
||||
const data = await res.json();
|
||||
pending = false;
|
||||
return data;
|
||||
} catch (e) {
|
||||
pending = false;
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||
|
||||
function renderStatus(state) {
|
||||
if (!statusChip) return;
|
||||
|
||||
// Completed
|
||||
if (state && state.completed) {
|
||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||
statusChip.style.display = 'inline-block';
|
||||
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||
statusChip.style.color = '#22c55e';
|
||||
markBtnIcon.style.display = 'none';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
|
||||
// In progress
|
||||
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
statusChip.textContent = `${pct}%`;
|
||||
statusChip.style.display = 'inline-block';
|
||||
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
const ORANGE_HEX = '#ea580c';
|
||||
statusChip.style.color = ORANGE_HEX;
|
||||
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
|
||||
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
||||
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
|
||||
// No progress
|
||||
statusChip.style.display = 'none';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = 'none';
|
||||
}
|
||||
|
||||
// ---- Event handlers (use currentName instead of rebinding per file) ----
|
||||
video.addEventListener("loadedmetadata", async () => {
|
||||
const nm = currentName;
|
||||
try {
|
||||
const state = await getProgress(nm);
|
||||
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
||||
video.currentTime = state.seconds;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
||||
} else {
|
||||
const ls = localStorage.getItem(lsKey(nm));
|
||||
if (ls) video.currentTime = parseFloat(ls);
|
||||
}
|
||||
renderStatus(state || null);
|
||||
} catch {
|
||||
renderStatus(null);
|
||||
}
|
||||
});
|
||||
|
||||
video.addEventListener("timeupdate", async () => {
|
||||
const now = Date.now();
|
||||
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
|
||||
lastSaveAt = now;
|
||||
|
||||
const nm = currentName;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
|
||||
sendProgress({ nm, seconds, duration });
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
|
||||
renderStatus({ seconds, duration, completed: false });
|
||||
});
|
||||
|
||||
video.addEventListener("ended", async () => {
|
||||
const nm = currentName;
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
});
|
||||
|
||||
markBtnIcon.onclick = async () => {
|
||||
const nm = currentName;
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
};
|
||||
|
||||
clearBtnIcon.onclick = async () => {
|
||||
const nm = currentName;
|
||||
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("progress_cleared") || "Progress cleared");
|
||||
setFileWatchedBadge(nm, false);
|
||||
renderStatus(null);
|
||||
};
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const nm = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setVideoSrc(nm);
|
||||
renderStatus(null);
|
||||
};
|
||||
|
||||
if (videos.length > 1) {
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||
const onKey = (e) => {
|
||||
if (!document.body.contains(overlay)) {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft") navigate(-1);
|
||||
if (e.key === "ArrowRight") navigate(+1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
overlay._onKey = onKey;
|
||||
}
|
||||
|
||||
// Kick off first video using the original working URL
|
||||
setVideoSrc(name);
|
||||
renderStatus(null);
|
||||
overlay.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
/* -------------------- AUDIO / OTHER -------------------- */
|
||||
if (isAudio) {
|
||||
const audio = document.createElement("audio");
|
||||
@@ -650,8 +855,19 @@ export function previewFile(fileUrl, fileName) {
|
||||
audio.className = "audio-modal";
|
||||
audio.style.maxWidth = "88vw";
|
||||
container.appendChild(audio);
|
||||
|
||||
// Share the same volume/mute behavior with videos
|
||||
loadSavedMediaVolume(audio);
|
||||
attachVolumePersistence(audio);
|
||||
|
||||
const downloadBtn = makeDownloadButton(folder, () => name);
|
||||
actionWrap.appendChild(downloadBtn);
|
||||
|
||||
overlay.style.display = "flex";
|
||||
} else {
|
||||
const downloadBtn = makeDownloadButton(folder, () => name);
|
||||
actionWrap.appendChild(downloadBtn);
|
||||
|
||||
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
|
||||
overlay.style.display = "flex";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -1020,6 +1066,41 @@ export function openColorFolderModal(folder) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addFolderActionButton(rowEl, folderPath) {
|
||||
if (!rowEl || !folderPath) return;
|
||||
if (rowEl.querySelector('.folder-kebab')) return; // avoid duplicates
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
// share styling with file list kebab
|
||||
btn.className = 'folder-kebab btn-actions-ellipsis material-icons';
|
||||
btn.textContent = 'more_vert';
|
||||
|
||||
const label = t('folder_actions') || 'Folder actions';
|
||||
btn.title = label;
|
||||
btn.setAttribute('aria-label', label);
|
||||
|
||||
// only control visibility/layout here; let CSS handle colors/hover
|
||||
Object.assign(btn.style, {
|
||||
display: 'none',
|
||||
marginLeft: '4px',
|
||||
flexShrink: '0'
|
||||
});
|
||||
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const x = rect.right;
|
||||
const y = rect.bottom;
|
||||
const opt = rowEl.querySelector('.folder-option');
|
||||
await openFolderActionsMenu(folderPath, opt, x, y);
|
||||
});
|
||||
|
||||
rowEl.appendChild(btn);
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
DOM builders & DnD
|
||||
----------------------*/
|
||||
@@ -1079,6 +1160,10 @@ function makeChildLi(parentPath, item) {
|
||||
|
||||
opt.append(icon, label);
|
||||
row.append(spacer, opt);
|
||||
|
||||
// Add 3-dot actions button for unlocked folders
|
||||
if (!locked) addFolderActionButton(row, fullPath);
|
||||
|
||||
li.append(row);
|
||||
|
||||
// <ul class="folder-tree collapsed" role="group"></ul>
|
||||
@@ -1254,6 +1339,28 @@ function getULForFolder(folder) {
|
||||
const li = opt ? opt.closest('li[role="treeitem"]') : null;
|
||||
return li ? li.querySelector(':scope > ul.folder-tree') : null;
|
||||
}
|
||||
|
||||
function updateFolderActionButtons() {
|
||||
const container = document.getElementById('folderTreeContainer');
|
||||
if (!container) return;
|
||||
|
||||
// Hide all kebabs by default
|
||||
container.querySelectorAll('.folder-kebab').forEach(btn => {
|
||||
btn.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show only for the currently selected, unlocked folder
|
||||
const selectedOpt = container.querySelector('.folder-option.selected');
|
||||
if (!selectedOpt || selectedOpt.classList.contains('locked')) return;
|
||||
|
||||
const row = selectedOpt.closest('.folder-row');
|
||||
if (!row) return;
|
||||
const kebab = row.querySelector('.folder-kebab');
|
||||
if (kebab) {
|
||||
kebab.style.display = 'inline-flex';
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFolder(selected) {
|
||||
const container = document.getElementById('folderTreeContainer');
|
||||
if (!container) return;
|
||||
@@ -1322,6 +1429,9 @@ async function selectFolder(selected) {
|
||||
saveFolderTreeState(st);
|
||||
try { await ensureChildrenLoaded(selected, ul); primeChildToggles(ul); } catch {}
|
||||
}
|
||||
|
||||
// Keep the 3-dot action aligned to the active folder
|
||||
updateFolderActionButtons();
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
@@ -1386,6 +1496,12 @@ export async function loadFolderTree(selectedFolder) {
|
||||
`;
|
||||
container.innerHTML = html;
|
||||
|
||||
// Add 3-dot actions button for root
|
||||
const rootRow = document.getElementById('rootRow');
|
||||
if (rootRow) {
|
||||
addFolderActionButton(rootRow, effectiveRoot);
|
||||
}
|
||||
|
||||
// Determine root's lock state
|
||||
const rootOpt = container.querySelector('.root-folder-option');
|
||||
let rootLocked = false;
|
||||
@@ -1608,13 +1724,57 @@ export function hideFolderManagerContextMenu() {
|
||||
if (menu) menu.hidden = true;
|
||||
}
|
||||
|
||||
async function openFolderActionsMenu(folder, targetEl, clientX, clientY) {
|
||||
if (!folder) return;
|
||||
|
||||
window.currentFolder = folder;
|
||||
await applyFolderCapabilities(folder);
|
||||
|
||||
// Clear previous selection in tree + breadcrumb
|
||||
document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
|
||||
|
||||
// Mark the clicked thing selected (folder-option or breadcrumb)
|
||||
if (targetEl) targetEl.classList.add('selected');
|
||||
|
||||
// Also sync selection in the tree if we invoked from a breadcrumb or kebab
|
||||
const tree = document.getElementById('folderTreeContainer');
|
||||
if (tree) {
|
||||
const inTree = tree.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
|
||||
if (inTree) inTree.classList.add('selected');
|
||||
}
|
||||
|
||||
// Show the kebab only for this selected folder
|
||||
updateFolderActionButtons();
|
||||
|
||||
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: t('create_folder'),
|
||||
action: () => {
|
||||
const modal = document.getElementById('createFolderModal');
|
||||
const input = document.getElementById('newFolderName');
|
||||
if (modal) modal.style.display = 'block';
|
||||
if (input) input.focus();
|
||||
}
|
||||
},
|
||||
{ label: t('move_folder'), action: () => openMoveFolderUI(folder) },
|
||||
{ label: t('rename_folder'), action: () => openRenameFolderModal() },
|
||||
...(canColor ? [{ label: t('color_folder'), action: () => openColorFolderModal(folder) }] : []),
|
||||
{ label: t('folder_share'), action: () => openFolderShareModal(folder) },
|
||||
{ label: t('delete_folder'), action: () => openDeleteFolderModal() },
|
||||
];
|
||||
|
||||
showFolderManagerContextMenu(clientX, clientY, menuItems);
|
||||
}
|
||||
|
||||
async function folderManagerContextMenuHandler(e) {
|
||||
const target = e.target.closest('.folder-option, .breadcrumb-link');
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Toggle-only for locked nodes
|
||||
// Toggle-only for locked nodes (no menu)
|
||||
if (target.classList && target.classList.contains('locked')) {
|
||||
const folder = target.getAttribute('data-folder') || '';
|
||||
const ul = getULForFolder(folder);
|
||||
@@ -1633,29 +1793,9 @@ async function folderManagerContextMenuHandler(e) {
|
||||
const folder = target.getAttribute('data-folder');
|
||||
if (!folder) return;
|
||||
|
||||
window.currentFolder = folder;
|
||||
await applyFolderCapabilities(folder);
|
||||
|
||||
document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
|
||||
target.classList.add('selected');
|
||||
|
||||
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
|
||||
|
||||
const menuItems = [
|
||||
{ label: t('create_folder'), action: () => {
|
||||
const modal = document.getElementById('createFolderModal');
|
||||
const input = document.getElementById('newFolderName');
|
||||
if (modal) modal.style.display = 'block';
|
||||
if (input) input.focus();
|
||||
}},
|
||||
{ label: t('move_folder'), action: () => openMoveFolderUI(folder) },
|
||||
{ label: t('rename_folder'), action: () => openRenameFolderModal() },
|
||||
...(canColor ? [{ label: t('color_folder'), action: () => openColorFolderModal(folder) }] : []),
|
||||
{ label: t('folder_share'), action: () => openFolderShareModal(folder) },
|
||||
{ label: t('delete_folder'), action: () => openDeleteFolderModal() },
|
||||
];
|
||||
|
||||
showFolderManagerContextMenu(e.clientX, e.clientY, menuItems);
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
await openFolderActionsMenu(folder, target, x, y);
|
||||
}
|
||||
|
||||
function bindFolderManagerContextMenu() {
|
||||
@@ -1688,6 +1828,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 +1899,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 +1942,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 +2005,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;
|
||||
|
||||
@@ -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'",
|
||||
@@ -185,6 +187,7 @@ const translations = {
|
||||
|
||||
// Admin Panel
|
||||
"header_settings": "Header Settings",
|
||||
"header_footer_settings": "Header & Footer Settings",
|
||||
"shared_max_upload_size_bytes_title": "Shared Max Upload Size",
|
||||
"shared_max_upload_size_bytes": "Shared Max Upload Size (bytes)",
|
||||
"max_bytes_shared_uploads_note": "Enter maximum bytes allowed for shared-folder uploads",
|
||||
@@ -233,7 +236,7 @@ const translations = {
|
||||
"error_generating_recovery_code": "Error generating recovery code",
|
||||
"error_loading_qr_code": "Error loading QR code.",
|
||||
"error_disabling_totp_setting": "Error disabling TOTP setting",
|
||||
"user_management": "User Management",
|
||||
"user_management": "Users, Groups & Access",
|
||||
"add_user": "Add User",
|
||||
"remove_user": "Remove User",
|
||||
"user_permissions": "User Permissions",
|
||||
@@ -268,7 +271,7 @@ const translations = {
|
||||
"columns": "Columns",
|
||||
"row_height": "Row Height",
|
||||
"api_docs": "API Docs",
|
||||
"show_folders_above_files": "Show folders above files",
|
||||
"show_folders_above_files": "Show folder strip above list",
|
||||
"display": "Display",
|
||||
"create_file": "Create File",
|
||||
"create_new_file": "Create New File",
|
||||
@@ -330,7 +333,27 @@ const translations = {
|
||||
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
|
||||
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
|
||||
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
|
||||
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder."
|
||||
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
|
||||
"load_more_folders": "Load More Folders",
|
||||
"show_inline_folders": "Show folders as rows above files",
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
"modified": "Modified",
|
||||
"created": "Created",
|
||||
"owner": "Owner",
|
||||
"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.",
|
||||
"svg_preview_disabled": "SVG preview is disabled for now for security reasons.",
|
||||
"no_files_or_folders": "No files or folders to display.",
|
||||
"no_preview_available": "No preview available.",
|
||||
"more_actions": "More Actions",
|
||||
"folder_actions": "Folder Actions",
|
||||
"disable_hover_preview": "Disable hover preview in file list",
|
||||
"zoom_in": "Zoom In",
|
||||
"zoom_out": "Zoom Out",
|
||||
"rotate_left": "Rotate Left",
|
||||
"rotate_right": "Rotate Right"
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
@@ -62,23 +62,43 @@ async function ensureToastReady() {
|
||||
}
|
||||
|
||||
function isDemoHost() {
|
||||
// Handles optional "www." just in case
|
||||
try {
|
||||
const cfg = window.__FR_SITE_CFG__ || {};
|
||||
if (typeof cfg.demoMode !== 'undefined') {
|
||||
return !!cfg.demoMode;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Fallback for older configs / direct demo host:
|
||||
return location.hostname.replace(/^www\./, '') === 'demo.filerise.net';
|
||||
}
|
||||
|
||||
function showLoginTip(message) {
|
||||
const tip = document.getElementById('fr-login-tip');
|
||||
if (!tip) return;
|
||||
tip.innerHTML = ''; // clear
|
||||
if (message) tip.append(document.createTextNode(message));
|
||||
if (location.hostname.replace(/^www\./, '') === 'demo.filerise.net') {
|
||||
const line = document.createElement('div'); line.style.marginTop = '6px';
|
||||
const mk = t => { const k = document.createElement('code'); k.textContent = t; return k; };
|
||||
line.append(document.createTextNode('Demo login — user: '), mk('demo'),
|
||||
document.createTextNode(' · pass: '), mk('demo'));
|
||||
tip.innerHTML = ''; // clear
|
||||
|
||||
if (message) {
|
||||
tip.append(document.createTextNode(message));
|
||||
}
|
||||
|
||||
if (isDemoHost()) {
|
||||
const line = document.createElement('div');
|
||||
line.style.marginTop = '6px';
|
||||
const mk = t => {
|
||||
const k = document.createElement('code');
|
||||
k.textContent = t;
|
||||
return k;
|
||||
};
|
||||
line.append(
|
||||
document.createTextNode('Demo login — user: '), mk('demo'),
|
||||
document.createTextNode(' · pass: '), mk('demo')
|
||||
);
|
||||
tip.append(line);
|
||||
}
|
||||
tip.style.display = 'block'; // reveal without shifting layout
|
||||
|
||||
tip.style.display = 'block';
|
||||
}
|
||||
|
||||
async function hideOverlaySmoothly(overlay) {
|
||||
@@ -225,6 +245,32 @@ window.__FR_FLAGS.entryStarted = window.__FR_FLAGS.entryStarted || false;
|
||||
return p.then(r => r.clone());
|
||||
};
|
||||
|
||||
// ---- Safe redirect helper (prevents open redirects) ----
|
||||
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const str = String(raw).trim();
|
||||
if (!str) return fallback;
|
||||
|
||||
const candidate = new URL(str, window.location.origin);
|
||||
|
||||
// Enforce same-origin
|
||||
if (candidate.origin !== window.location.origin) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Limit to http/https
|
||||
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Return relative URL
|
||||
return candidate.pathname + candidate.search + candidate.hash;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// Gentle toast normalizer (compatible with showToast(message, duration))
|
||||
const origToast = window.showToast;
|
||||
if (typeof origToast === 'function' && !origToast.__frWrapped) {
|
||||
@@ -399,107 +445,127 @@ function bindDarkMode() {
|
||||
m.content = val;
|
||||
};
|
||||
|
||||
// ---------- site config / auth ----------
|
||||
function applySiteConfig(cfg, { phase = 'final' } = {}) {
|
||||
try {
|
||||
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
|
||||
|
||||
// Always keep <title> correct early (no visual flicker)
|
||||
document.title = title;
|
||||
// --- Header logo (branding) in BOTH phases ---
|
||||
// ---------- site config / auth ----------
|
||||
function applySiteConfig(cfg, { phase = 'final' } = {}) {
|
||||
try {
|
||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||
const customLogoUrl = branding.customLogoUrl || "";
|
||||
const logoImg = document.querySelector('.header-logo img');
|
||||
if (logoImg) {
|
||||
if (customLogoUrl) {
|
||||
logoImg.setAttribute('src', customLogoUrl);
|
||||
logoImg.setAttribute('alt', 'Site logo');
|
||||
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
|
||||
|
||||
// Always keep <title> correct early (no visual flicker)
|
||||
document.title = title;
|
||||
|
||||
// --- Header logo (branding) in BOTH phases ---
|
||||
try {
|
||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||
const customLogoUrl = branding.customLogoUrl || "";
|
||||
const logoImg = document.querySelector('.header-logo img');
|
||||
if (logoImg) {
|
||||
if (customLogoUrl) {
|
||||
logoImg.setAttribute('src', customLogoUrl);
|
||||
logoImg.setAttribute('alt', 'Site logo');
|
||||
} else {
|
||||
// fall back to default FileRise logo
|
||||
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
|
||||
logoImg.setAttribute('alt', 'FileRise');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// non-fatal; ignore branding issues
|
||||
}
|
||||
|
||||
// --- Header colors (branding) in BOTH phases ---
|
||||
try {
|
||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||
const root = document.documentElement;
|
||||
|
||||
const light = branding.headerBgLight || '';
|
||||
const dark = branding.headerBgDark || '';
|
||||
|
||||
if (light) root.style.setProperty('--header-bg-light', light);
|
||||
else root.style.removeProperty('--header-bg-light');
|
||||
|
||||
if (dark) root.style.setProperty('--header-bg-dark', dark);
|
||||
else root.style.removeProperty('--header-bg-dark');
|
||||
} catch (e) {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
// --- Footer HTML (branding) in BOTH phases ---
|
||||
try {
|
||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||
const footerEl = document.getElementById('siteFooter');
|
||||
if (footerEl) {
|
||||
const html = (branding.footerHtml || '').trim();
|
||||
if (html) {
|
||||
// allow simple HTML from config
|
||||
footerEl.innerHTML = html;
|
||||
} else {
|
||||
const year = new Date().getFullYear();
|
||||
footerEl.innerHTML =
|
||||
`© ${year} <a href="https://filerise.net" target="_blank" rel="noopener noreferrer">FileRise</a>`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
// --- Login options (apply in BOTH phases so login page is correct) ---
|
||||
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
||||
|
||||
// be tolerant to key variants just in case
|
||||
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
|
||||
|
||||
const showForm = !disableForm;
|
||||
const showOIDC = !disableOIDC;
|
||||
const showBasic = !disableBasic;
|
||||
|
||||
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
|
||||
const authForm = $('#authForm'); // inner username/password form
|
||||
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
|
||||
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||
|
||||
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
|
||||
if (loginWrap) {
|
||||
const anyMethod = showForm || showOIDC || showBasic;
|
||||
if (anyMethod) {
|
||||
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
|
||||
loginWrap.style.display = ''; // let CSS decide
|
||||
} else {
|
||||
// fall back to default FileRise logo
|
||||
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
|
||||
logoImg.setAttribute('alt', 'FileRise');
|
||||
loginWrap.setAttribute('hidden', '');
|
||||
loginWrap.style.display = '';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// non-fatal; ignore branding issues
|
||||
}
|
||||
// --- Header colors (branding) in BOTH phases ---
|
||||
try {
|
||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||
const root = document.documentElement;
|
||||
|
||||
const light = branding.headerBgLight || '';
|
||||
const dark = branding.headerBgDark || '';
|
||||
|
||||
if (light) root.style.setProperty('--header-bg-light', light);
|
||||
else root.style.removeProperty('--header-bg-light');
|
||||
|
||||
if (dark) root.style.setProperty('--header-bg-dark', dark);
|
||||
else root.style.removeProperty('--header-bg-dark');
|
||||
} catch (e) {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
// --- Login options (apply in BOTH phases so login page is correct) ---
|
||||
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
||||
|
||||
|
||||
// be tolerant to key variants just in case
|
||||
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
|
||||
|
||||
const showForm = !disableForm;
|
||||
const showOIDC = !disableOIDC;
|
||||
const showBasic = !disableBasic;
|
||||
|
||||
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
|
||||
const authForm = $('#authForm'); // inner username/password form
|
||||
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
|
||||
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||
|
||||
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
|
||||
if (loginWrap) {
|
||||
const anyMethod = showForm || showOIDC || showBasic;
|
||||
if (anyMethod) {
|
||||
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
|
||||
loginWrap.style.display = ''; // let CSS decide
|
||||
} else {
|
||||
loginWrap.setAttribute('hidden', '');
|
||||
loginWrap.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Toggle the pieces inside the wrapper
|
||||
if (authForm) authForm.style.display = showForm ? '' : 'none';
|
||||
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
|
||||
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
|
||||
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
||||
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
||||
|
||||
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
|
||||
if (phase === 'final') {
|
||||
const h1 = document.querySelector('.header-title h1');
|
||||
if (h1) {
|
||||
// prevent i18n or legacy from overwriting it
|
||||
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
|
||||
|
||||
if (h1.textContent !== title) h1.textContent = title;
|
||||
|
||||
// lock it so late code can't stomp it
|
||||
if (!h1.__titleLock) {
|
||||
const mo = new MutationObserver(() => {
|
||||
if (h1.textContent !== title) h1.textContent = title;
|
||||
});
|
||||
mo.observe(h1, { childList: true, characterData: true, subtree: true });
|
||||
h1.__titleLock = mo;
|
||||
|
||||
// 2) Toggle the pieces inside the wrapper
|
||||
if (authForm) authForm.style.display = showForm ? '' : 'none';
|
||||
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
|
||||
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
|
||||
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
||||
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
||||
|
||||
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
|
||||
if (phase === 'final') {
|
||||
const h1 = document.querySelector('.header-title h1');
|
||||
if (h1) {
|
||||
// prevent i18n or legacy from overwriting it
|
||||
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
|
||||
|
||||
if (h1.textContent !== title) h1.textContent = title;
|
||||
|
||||
// lock it so late code can't stomp it
|
||||
if (!h1.__titleLock) {
|
||||
const mo = new MutationObserver(() => {
|
||||
if (h1.textContent !== title) h1.textContent = title;
|
||||
});
|
||||
mo.observe(h1, { childList: true, characterData: true, subtree: true });
|
||||
h1.__titleLock = mo;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
async function readyToReveal() {
|
||||
// Wait for CSS + fonts so the first revealed frame is fully styled
|
||||
@@ -526,11 +592,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 +951,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 }) => {
|
||||
|
||||
401
public/js/portal-login.js
Normal file
@@ -0,0 +1,401 @@
|
||||
// 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');
|
||||
const logoEl = document.getElementById('portalLoginLogo');
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// 🔹 Portal logo: use logoFile from metadata if present
|
||||
if (logoEl) {
|
||||
let logoSrc = null;
|
||||
|
||||
// If you ever decide to store a direct URL:
|
||||
if (portal.logoUrl && portal.logoUrl.trim()) {
|
||||
logoSrc = portal.logoUrl.trim();
|
||||
} else if (portal.logoFile && portal.logoFile.trim()) {
|
||||
// Same convention as portal.html: files live in uploads/profile_pics
|
||||
logoSrc = '/uploads/profile_pics/' + portal.logoFile.trim();
|
||||
}
|
||||
|
||||
if (logoSrc) {
|
||||
logoEl.src = logoSrc;
|
||||
logoEl.alt = title;
|
||||
}
|
||||
}
|
||||
|
||||
// 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';
|
||||
}
|
||||
});
|
||||
});
|
||||
1111
public/js/portal.js
Normal file
@@ -39,6 +39,78 @@ 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 setUploadButtonVisible(visible) {
|
||||
const btn = document.getElementById('uploadBtn');
|
||||
if (!btn) return;
|
||||
|
||||
btn.style.display = visible ? 'block' : 'none';
|
||||
btn.disabled = !visible;
|
||||
}
|
||||
|
||||
function getUserDraftContext() {
|
||||
const all = loadResumableDraftsAll();
|
||||
const userKey = getCurrentUserKey();
|
||||
@@ -253,23 +325,37 @@ 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();
|
||||
|
||||
setUploadButtonVisible(false);
|
||||
}
|
||||
|
||||
function adjustFolderHelpExpansion() {
|
||||
@@ -388,6 +474,8 @@ function createFileEntry(file) {
|
||||
|
||||
li.remove();
|
||||
updateFileInfoCount();
|
||||
const anyItems = !!document.querySelector('li.upload-progress-item');
|
||||
setUploadButtonVisible(anyItems);
|
||||
});
|
||||
li.removeBtn = removeBtn;
|
||||
li.appendChild(removeBtn);
|
||||
@@ -598,6 +686,7 @@ function processFiles(filesInput) {
|
||||
|
||||
window.selectedFiles = files;
|
||||
updateFileInfoCount();
|
||||
setUploadButtonVisible(files.length > 0);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------
|
||||
@@ -608,6 +697,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 +734,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 +755,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,12 +777,13 @@ async function initResumableUpload() {
|
||||
} else {
|
||||
list = listWrapper.querySelector("ul.upload-progress-list");
|
||||
}
|
||||
|
||||
|
||||
const li = createFileEntry(file);
|
||||
li.dataset.uploadIndex = file.uniqueIdentifier;
|
||||
list.appendChild(li);
|
||||
updateFileInfoCount();
|
||||
updateResumableQuery();
|
||||
setUploadButtonVisible(true);
|
||||
});
|
||||
|
||||
resumableInstance.on("fileProgress", function (file) {
|
||||
@@ -852,6 +945,7 @@ async function initResumableUpload() {
|
||||
}
|
||||
clearResumableDraftsForFolder(window.currentFolder || 'root');
|
||||
showResumableDraftBanner();
|
||||
setUploadButtonVisible(false);
|
||||
}, 5000);
|
||||
} else {
|
||||
showToast("Some files failed to upload. Please check the list.");
|
||||
@@ -1104,6 +1198,8 @@ function submitFiles(allFiles) {
|
||||
} else {
|
||||
showToast(`${succeeded} file(s) succeeded. Please check the list.`);
|
||||
}
|
||||
const anyItems = !!document.querySelector('li.upload-progress-item');
|
||||
setUploadButtonVisible(anyItems);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error fetching file list:", error);
|
||||
@@ -1119,9 +1215,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 +1235,50 @@ function initUpload() {
|
||||
fileInput.setAttribute("multiple", "");
|
||||
}
|
||||
|
||||
setDropAreaDefault();
|
||||
|
||||
// Drag–and–drop 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 +1292,8 @@ function initUpload() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have any files queued in Resumable, treat this as a resumable upload.
|
||||
setUploadButtonVisible(false);
|
||||
|
||||
const hasResumableFiles =
|
||||
useResumable &&
|
||||
resumableInstance &&
|
||||
@@ -1215,7 +1303,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 +1310,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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v1.9.10';
|
||||
window.APP_VERSION = 'v2.3.5';
|
||||
|
||||
92
public/js/zoom.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
148
public/portal-login.html
Normal file
@@ -0,0 +1,148 @@
|
||||
<!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 id="portalLoginLogo"
|
||||
src="/assets/logo.svg?v={{APP_QVER}}"
|
||||
alt="FileRise">
|
||||
<div>
|
||||
<div id="portalLoginTitle" class="portal-login-title">
|
||||
Sign in to Client Portal
|
||||
</div>
|
||||
<div id="portalLoginSubtitle" class="portal-login-subtitle">
|
||||
to access this client portal
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="portalLoginError" class="alert alert-danger"></div>
|
||||
|
||||
<form id="portalLoginForm" novalidate>
|
||||
<div class="form-group">
|
||||
<label for="portalLoginUser">Username or email</label>
|
||||
<input type="text"
|
||||
class="form-control form-control-sm"
|
||||
id="portalLoginUser"
|
||||
autocomplete="username"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="portalLoginPass">Password</label>
|
||||
<input type="password"
|
||||
class="form-control form-control-sm"
|
||||
id="portalLoginPass"
|
||||
autocomplete="current-password"
|
||||
required>
|
||||
</div>
|
||||
<button type="submit"
|
||||
id="portalLoginSubmit"
|
||||
class="btn btn-primary btn-sm btn-block">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<small id="portalLoginHint"
|
||||
class="text-muted d-block mt-2"
|
||||
style="font-size:0.75rem;">
|
||||
You’ll be sent back to the portal automatically after signing in.
|
||||
</small>
|
||||
|
||||
<small id="portalLoginFooter"
|
||||
class="text-muted d-block mt-1"
|
||||
style="font-size:0.7rem; display:none;">
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
381
public/portal.html
Normal file
@@ -0,0 +1,381 @@
|
||||
<!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;
|
||||
}
|
||||
.portal-required-star {
|
||||
color: #dc3545;
|
||||
}
|
||||
.portal-dropzone.portal-dropzone-disabled {
|
||||
opacity: 0.5;
|
||||
border-style: solid;
|
||||
pointer-events: 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 id="portalFormGroupName" class="form-group" style="margin-bottom:6px;">
|
||||
<label id="portalFormLabelName" for="portalFormName">Name</label>
|
||||
<input type="text" id="portalFormName" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div id="portalFormGroupEmail" class="form-group" style="margin-bottom:6px;">
|
||||
<label id="portalFormLabelEmail" for="portalFormEmail">Email</label>
|
||||
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div id="portalFormGroupReference" class="form-group" style="margin-bottom:6px;">
|
||||
<label id="portalFormLabelReference" for="portalFormReference">Reference / Case / Order #</label>
|
||||
<input type="text" id="portalFormReference" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div id="portalFormGroupNotes" class="form-group" style="margin-bottom:8px;">
|
||||
<label id="portalFormLabelNotes" 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="portalThankYouSection"
|
||||
style="margin-top:12px; display:none;">
|
||||
<div class="alert alert-success" style="font-size:0.9rem; margin-bottom:8px;">
|
||||
<strong>Thank you!</strong>
|
||||
<span id="portalThankYouMessage">
|
||||
Your files have been uploaded.
|
||||
</span>
|
||||
</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>
|
||||
BIN
resources/StorageDiskUsage.png
Normal file
|
After Width: | Height: | Size: 738 KiB |
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 535 KiB |
BIN
resources/dark-client-portal1.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
resources/dark-client-portal2.png
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
resources/dark-client-portal3.png
Normal file
|
After Width: | Height: | Size: 410 KiB |
BIN
resources/dark-client-portal4.png
Normal file
|
After Width: | Height: | Size: 511 KiB |
|
Before Width: | Height: | Size: 470 KiB After Width: | Height: | Size: 871 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 645 KiB After Width: | Height: | Size: 581 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 698 KiB |
BIN
resources/dark-user-groups.png
Normal file
|
After Width: | Height: | Size: 501 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 656 KiB |
BIN
resources/filerise-v2.0.0.png
Normal file
|
After Width: | Height: | Size: 737 KiB |
BIN
resources/filerise-v2.3.2.png
Normal file
|
After Width: | Height: | Size: 1002 KiB |
BIN
resources/filerise-v2.3.4.png
Normal file
|
After Width: | Height: | Size: 914 KiB |
BIN
resources/portal-login.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
resources/portal-optional-form.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
@@ -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.3.2 (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.3.2"
|
||||
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 (don’t fail the whole script if reload isn’t available)
|
||||
# 7) Reload Apache (don’t fail the whole script if reload isn’t 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."
|
||||
42
src/cli/disk_usage_scan.php
Normal 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);
|
||||
}
|
||||
166
src/controllers/AclAdminController.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
// src/controllers/AclAdminController.php
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||
|
||||
class AclAdminController
|
||||
{
|
||||
|
||||
public function getUserGrants(string $user): array
|
||||
{
|
||||
if (!preg_match(REGEX_USER, $user)) {
|
||||
throw new InvalidArgumentException('Invalid user');
|
||||
}
|
||||
|
||||
$folders = [];
|
||||
try {
|
||||
$rows = FolderModel::getFolderList();
|
||||
if (is_array($rows)) {
|
||||
foreach ($rows as $r) {
|
||||
$f = is_array($r) ? ($r['folder'] ?? '') : (string)$r;
|
||||
if ($f !== '') $folders[$f] = true;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore, fall back to ACL file
|
||||
}
|
||||
|
||||
if (empty($folders)) {
|
||||
$aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||
if (is_file($aclPath)) {
|
||||
$data = json_decode((string)@file_get_contents($aclPath), true);
|
||||
if (is_array($data['folders'] ?? null)) {
|
||||
foreach ($data['folders'] as $name => $_) {
|
||||
$folders[$name] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$folderList = array_keys($folders);
|
||||
if (!in_array('root', $folderList, true)) {
|
||||
array_unshift($folderList, 'root');
|
||||
}
|
||||
|
||||
$has = function(array $arr, string $u): bool {
|
||||
foreach ($arr as $x) {
|
||||
if (strcasecmp((string)$x, $u) === 0) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
$out = [];
|
||||
foreach ($folderList as $f) {
|
||||
$rec = ACL::explicitAll($f);
|
||||
|
||||
$isOwner = $has($rec['owners'], $user);
|
||||
$canViewAll = $isOwner || $has($rec['read'], $user);
|
||||
$canViewOwn = $has($rec['read_own'], $user);
|
||||
$canShare = $isOwner || $has($rec['share'], $user);
|
||||
$canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user);
|
||||
|
||||
if (
|
||||
$canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner
|
||||
|| $has($rec['create'], $user) || $has($rec['edit'], $user) || $has($rec['rename'], $user)
|
||||
|| $has($rec['copy'], $user) || $has($rec['move'], $user) || $has($rec['delete'], $user)
|
||||
|| $has($rec['extract'], $user) || $has($rec['share_file'], $user) || $has($rec['share_folder'], $user)
|
||||
) {
|
||||
$out[$f] = [
|
||||
'view' => $canViewAll,
|
||||
'viewOwn' => $canViewOwn,
|
||||
'write' => $has($rec['write'], $user) || $isOwner,
|
||||
'manage' => $isOwner,
|
||||
'share' => $canShare,
|
||||
'create' => $isOwner || $has($rec['create'], $user),
|
||||
'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'], $user),
|
||||
'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'], $user),
|
||||
'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'], $user),
|
||||
'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'], $user),
|
||||
'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'], $user),
|
||||
'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'], $user),
|
||||
'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'], $user),
|
||||
'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'], $user),
|
||||
'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'], $user),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function saveUserGrantsPayload(array $payload): array
|
||||
{
|
||||
|
||||
$normalizeCaps = function (array $row): array {
|
||||
$bool = function ($v) {
|
||||
return !empty($v) && $v !== 'false' && $v !== 0;
|
||||
};
|
||||
$k = [
|
||||
'view','viewOwn','upload','manage','share',
|
||||
'create','edit','rename','copy','move','delete','extract',
|
||||
'shareFile','shareFolder','write'
|
||||
];
|
||||
$out = [];
|
||||
foreach ($k as $kk) {
|
||||
$out[$kk] = $bool($row[$kk] ?? false);
|
||||
}
|
||||
|
||||
if ($out['shareFolder'] && !$out['view']) {
|
||||
$out['view'] = true;
|
||||
}
|
||||
if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) {
|
||||
$out['viewOwn'] = true;
|
||||
}
|
||||
|
||||
return $out;
|
||||
};
|
||||
|
||||
$sanitizeGrantsMap = function (array $grants) use ($normalizeCaps): array {
|
||||
$out = [];
|
||||
foreach ($grants as $folder => $caps) {
|
||||
if (!is_string($folder)) $folder = (string)$folder;
|
||||
if (!is_array($caps)) $caps = [];
|
||||
$out[$folder] = $normalizeCaps($caps);
|
||||
}
|
||||
return $out;
|
||||
};
|
||||
|
||||
$validUser = function (string $u): bool {
|
||||
return ($u !== '' && preg_match(REGEX_USER, $u));
|
||||
};
|
||||
|
||||
// Single-user mode
|
||||
if (isset($payload['user'], $payload['grants']) && is_array($payload['grants'])) {
|
||||
$user = trim((string)$payload['user']);
|
||||
if (!$validUser($user)) {
|
||||
throw new InvalidArgumentException('Invalid user');
|
||||
}
|
||||
|
||||
$grants = $sanitizeGrantsMap($payload['grants']);
|
||||
|
||||
return ACL::applyUserGrantsAtomic($user, $grants);
|
||||
}
|
||||
|
||||
// Batch mode
|
||||
if (isset($payload['changes']) && is_array($payload['changes'])) {
|
||||
$updated = [];
|
||||
foreach ($payload['changes'] as $chg) {
|
||||
if (!is_array($chg)) continue;
|
||||
$user = trim((string)($chg['user'] ?? ''));
|
||||
$gr = $chg['grants'] ?? null;
|
||||
if (!$validUser($user) || !is_array($gr)) continue;
|
||||
|
||||
try {
|
||||
$res = ACL::applyUserGrantsAtomic($user, $sanitizeGrantsMap($gr));
|
||||
$updated[$user] = $res['updated'] ?? [];
|
||||
} catch (\Throwable $e) {
|
||||
$updated[$user] = ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
return ['ok' => true, 'updated' => $updated];
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Invalid payload: expected {user,grants} or {changes:[{user,grants}]}');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// src/controllers/AdminController.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
@@ -8,7 +9,7 @@ class AdminController
|
||||
{
|
||||
|
||||
/** Enforce authentication (401). */
|
||||
private static function requireAuth(): void
|
||||
public static function requireAuth(): void
|
||||
{
|
||||
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
@@ -19,7 +20,7 @@ class AdminController
|
||||
}
|
||||
|
||||
/** Enforce admin (401). */
|
||||
private static function requireAdmin(): void
|
||||
public static function requireAdmin(): void
|
||||
{
|
||||
self::requireAuth();
|
||||
|
||||
@@ -69,7 +70,7 @@ class AdminController
|
||||
}
|
||||
|
||||
/** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
|
||||
private static function requireCsrf(): void
|
||||
public static function requireCsrf(): void
|
||||
{
|
||||
$h = self::headersLower();
|
||||
$token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
|
||||
@@ -143,6 +144,9 @@ class AdminController
|
||||
$proType = $proPayload['type'] ?? null;
|
||||
$proEmail = $proPayload['email'] ?? null;
|
||||
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
||||
$proPlan = $proPayload['plan'] ?? null;
|
||||
$proExpiresAt = $proPayload['expiresAt'] ?? null;
|
||||
$proMaxMajor = $proPayload['maxMajor'] ?? null;
|
||||
|
||||
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
|
||||
$public = [
|
||||
@@ -168,6 +172,7 @@ class AdminController
|
||||
'customLogoUrl' => (string)($config['branding']['customLogoUrl'] ?? ''),
|
||||
'headerBgLight' => (string)($config['branding']['headerBgLight'] ?? ''),
|
||||
'headerBgDark' => (string)($config['branding']['headerBgDark'] ?? ''),
|
||||
'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''),
|
||||
],
|
||||
'pro' => [
|
||||
'active' => $proActive,
|
||||
@@ -175,7 +180,11 @@ class AdminController
|
||||
'email' => $proEmail,
|
||||
'version' => $proVersion,
|
||||
'license' => $licenseString,
|
||||
'plan' => $proPlan,
|
||||
'expiresAt' => $proExpiresAt,
|
||||
'maxMajor' => $proMaxMajor,
|
||||
],
|
||||
'demoMode' => defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false,
|
||||
];
|
||||
|
||||
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
|
||||
@@ -240,7 +249,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 +281,246 @@ 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.');
|
||||
}
|
||||
|
||||
$data = ['portals' => []];
|
||||
$invalid = [];
|
||||
|
||||
foreach ($portalsPayload as $slug => $info) {
|
||||
$slug = trim((string)$slug);
|
||||
|
||||
if (!is_array($info)) {
|
||||
$info = [];
|
||||
}
|
||||
|
||||
$label = trim((string)($info['label'] ?? $slug));
|
||||
$folder = trim((string)($info['folder'] ?? ''));
|
||||
|
||||
// Require both slug and folder; collect invalid ones so the UI can warn.
|
||||
if ($slug === '' || $folder === '') {
|
||||
$invalid[] = $label !== '' ? $label : ($slug !== '' ? $slug : '(unnamed portal)');
|
||||
continue;
|
||||
}
|
||||
|
||||
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
|
||||
$uploadOnly = !empty($info['uploadOnly']);
|
||||
$allowDownload = array_key_exists('allowDownload', $info)
|
||||
? !empty($info['allowDownload'])
|
||||
: true;
|
||||
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
|
||||
|
||||
// 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'] ?? ''));
|
||||
|
||||
// Optional logo info
|
||||
$logoFile = trim((string)($info['logoFile'] ?? ''));
|
||||
$logoUrl = trim((string)($info['logoUrl'] ?? ''));
|
||||
|
||||
// Upload rules / thank-you behavior
|
||||
$uploadMaxSizeMb = isset($info['uploadMaxSizeMb']) ? (int)$info['uploadMaxSizeMb'] : 0;
|
||||
$uploadExtWhitelist = trim((string)($info['uploadExtWhitelist'] ?? ''));
|
||||
$uploadMaxPerDay = isset($info['uploadMaxPerDay']) ? (int)$info['uploadMaxPerDay'] : 0;
|
||||
$showThankYou = !empty($info['showThankYou']);
|
||||
$thankYouText = trim((string)($info['thankYouText'] ?? ''));
|
||||
|
||||
// Form defaults
|
||||
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
|
||||
? $info['formDefaults']
|
||||
: [];
|
||||
|
||||
$formDefaults = [
|
||||
'name' => trim((string)($formDefaults['name'] ?? '')),
|
||||
'email' => trim((string)($formDefaults['email'] ?? '')),
|
||||
'reference' => trim((string)($formDefaults['reference'] ?? '')),
|
||||
'notes' => trim((string)($formDefaults['notes'] ?? '')),
|
||||
];
|
||||
|
||||
// Required flags
|
||||
$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']),
|
||||
];
|
||||
|
||||
// Labels
|
||||
$formLabels = isset($info['formLabels']) && is_array($info['formLabels'])
|
||||
? $info['formLabels']
|
||||
: [];
|
||||
|
||||
$formLabels = [
|
||||
'name' => trim((string)($formLabels['name'] ?? 'Name')),
|
||||
'email' => trim((string)($formLabels['email'] ?? 'Email')),
|
||||
'reference' => trim((string)($formLabels['reference'] ?? 'Reference / Case / Order #')),
|
||||
'notes' => trim((string)($formLabels['notes'] ?? 'Notes')),
|
||||
];
|
||||
|
||||
// Visibility
|
||||
$formVisible = isset($info['formVisible']) && is_array($info['formVisible'])
|
||||
? $info['formVisible']
|
||||
: [];
|
||||
|
||||
$formVisible = [
|
||||
'name' => !array_key_exists('name', $formVisible) || !empty($formVisible['name']),
|
||||
'email' => !array_key_exists('email', $formVisible) || !empty($formVisible['email']),
|
||||
'reference' => !array_key_exists('reference', $formVisible) || !empty($formVisible['reference']),
|
||||
'notes' => !array_key_exists('notes', $formVisible) || !empty($formVisible['notes']),
|
||||
];
|
||||
|
||||
|
||||
|
||||
$data['portals'][$slug] = [
|
||||
'label' => $label,
|
||||
'folder' => $folder,
|
||||
'clientEmail' => $clientEmail,
|
||||
'uploadOnly' => $uploadOnly,
|
||||
'allowDownload' => $allowDownload,
|
||||
'expiresAt' => $expiresAt,
|
||||
'title' => $title,
|
||||
'introText' => $introText,
|
||||
'requireForm' => $requireForm,
|
||||
'brandColor' => $brandColor,
|
||||
'footerText' => $footerText,
|
||||
'logoFile' => $logoFile,
|
||||
'logoUrl' => $logoUrl,
|
||||
'uploadMaxSizeMb' => $uploadMaxSizeMb,
|
||||
'uploadExtWhitelist' => $uploadExtWhitelist,
|
||||
'uploadMaxPerDay' => $uploadMaxPerDay,
|
||||
'showThankYou' => $showThankYou,
|
||||
'thankYouText' => $thankYouText,
|
||||
'formDefaults' => $formDefaults,
|
||||
'formRequired' => $formRequired,
|
||||
'formLabels' => $formLabels,
|
||||
'formVisible' => $formVisible,
|
||||
];
|
||||
}
|
||||
if (!empty($invalid)) {
|
||||
throw new InvalidArgumentException(
|
||||
'One or more portals are missing a slug or folder: ' . implode(', ', $invalid)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
||||
$ok = $store->savePortals($data);
|
||||
|
||||
if (!$ok) {
|
||||
throw new RuntimeException('Could not write portals.json');
|
||||
}
|
||||
}
|
||||
|
||||
public function getProGroups(): array
|
||||
{
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
||||
throw new RuntimeException('FileRise Pro is not active.');
|
||||
}
|
||||
|
||||
$proGroupsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProGroups.php';
|
||||
if (!is_file($proGroupsPath)) {
|
||||
throw new RuntimeException('ProGroups.php not found in Pro bundle.');
|
||||
}
|
||||
|
||||
require_once $proGroupsPath;
|
||||
|
||||
$store = new ProGroups(FR_PRO_BUNDLE_DIR);
|
||||
$groups = $store->listGroups();
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $groupsPayload Raw "groups" array from JSON body
|
||||
*/
|
||||
public function saveProGroups(array $groupsPayload): void
|
||||
{
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
||||
throw new RuntimeException('FileRise Pro is not active.');
|
||||
}
|
||||
|
||||
$proGroupsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProGroups.php';
|
||||
if (!is_file($proGroupsPath)) {
|
||||
throw new RuntimeException('ProGroups.php not found in Pro bundle.');
|
||||
}
|
||||
|
||||
require_once $proGroupsPath;
|
||||
|
||||
// Normalize / validate the payload into the canonical structure
|
||||
if (!is_array($groupsPayload)) {
|
||||
throw new InvalidArgumentException('Invalid groups format.');
|
||||
}
|
||||
|
||||
$data = ['groups' => []];
|
||||
|
||||
foreach ($groupsPayload as $name => $info) {
|
||||
$name = trim((string)$name);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = isset($info['label']) ? trim((string)$info['label']) : $name;
|
||||
$members = isset($info['members']) && is_array($info['members']) ? $info['members'] : [];
|
||||
$grants = isset($info['grants']) && is_array($info['grants']) ? $info['grants'] : [];
|
||||
|
||||
$data['groups'][$name] = [
|
||||
'name' => $name,
|
||||
'label' => $label,
|
||||
'members' => array_values(array_unique(array_map('strval', $members))),
|
||||
'grants' => $grants,
|
||||
];
|
||||
}
|
||||
|
||||
$store = new ProGroups(FR_PRO_BUNDLE_DIR);
|
||||
if (!$store->save($data)) {
|
||||
throw new RuntimeException('Could not write groups.json');
|
||||
}
|
||||
}
|
||||
|
||||
public function installProBundle(): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
@@ -349,6 +598,28 @@ public function installProBundle(): void
|
||||
return;
|
||||
}
|
||||
|
||||
// NEW: normalize to basename so C:\fakepath\FileRisePro-v1.2.1.zip works.
|
||||
$basename = $origName;
|
||||
if ($basename !== '') {
|
||||
// Normalize slashes and then take basename
|
||||
$basename = str_replace('\\', '/', $basename);
|
||||
$basename = basename($basename);
|
||||
}
|
||||
|
||||
// Try to parse the bundle version from the *basename*
|
||||
// Supports: FileRisePro-v1.2.3.zip or FileRisePro_1.2.3.zip (case-insensitive)
|
||||
$declaredVersion = null;
|
||||
if (
|
||||
$basename !== '' &&
|
||||
preg_match(
|
||||
'/^FileRisePro[_-]v?([0-9]+\.[0-9]+\.[0-9]+)\.zip$/i',
|
||||
$basename,
|
||||
$m
|
||||
)
|
||||
) {
|
||||
$declaredVersion = 'v' . $m[1];
|
||||
}
|
||||
|
||||
// Prepare temp working dir
|
||||
$tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
|
||||
$workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
|
||||
@@ -374,16 +645,16 @@ public function installProBundle(): void
|
||||
|
||||
$installed = [
|
||||
'src' => [],
|
||||
'public' => [],
|
||||
'docs' => [],
|
||||
];
|
||||
|
||||
$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;
|
||||
@@ -436,21 +707,6 @@ public function installProBundle(): void
|
||||
$targetPath = $bundleRoot . DIRECTORY_SEPARATOR . $relative;
|
||||
$category = 'src';
|
||||
|
||||
} elseif (strpos($name, 'public/api/pro/') === 0) {
|
||||
// e.g. public/api/pro/uploadBrandLogo.php
|
||||
$relative = substr($name, strlen('public/api/pro/'));
|
||||
if ($relative === '' || substr($relative, -1) === '/') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Persist under bundle dir so it survives image rebuilds:
|
||||
// users/pro/public/api/pro/...
|
||||
$targetPath = $bundleRoot
|
||||
. DIRECTORY_SEPARATOR . 'public'
|
||||
. DIRECTORY_SEPARATOR . 'api'
|
||||
. DIRECTORY_SEPARATOR . 'pro'
|
||||
. DIRECTORY_SEPARATOR . $relative;
|
||||
$category = 'public';
|
||||
} else {
|
||||
// Skip anything outside these prefixes
|
||||
continue;
|
||||
@@ -506,20 +762,36 @@ public function installProBundle(): void
|
||||
// Best-effort cleanup; ignore failures
|
||||
@unlink($zipPath);
|
||||
@rmdir($workDir);
|
||||
|
||||
|
||||
// NEW: ensure OPcache picks up new Pro bundle code immediately
|
||||
if (function_exists('opcache_invalidate')) {
|
||||
foreach ($installed['src'] as $pathInfo) {
|
||||
// strip " (overwritten)" suffix if present
|
||||
$path = preg_replace('/\s+\(overwritten\)$/', '', $pathInfo);
|
||||
if (is_string($path) && $path !== '' && is_file($path)) {
|
||||
@opcache_invalidate($path, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reflect current Pro status in response if bootstrap was loaded
|
||||
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
|
||||
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
|
||||
|
||||
$reportedVersion = $declaredVersion;
|
||||
if ($reportedVersion === null && defined('FR_PRO_BUNDLE_VERSION')) {
|
||||
$reportedVersion = FR_PRO_BUNDLE_VERSION;
|
||||
}
|
||||
|
||||
$proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)
|
||||
? (FR_PRO_INFO['payload'] ?? null)
|
||||
: null;
|
||||
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
||||
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Pro bundle installed.',
|
||||
'installed' => $installed,
|
||||
'proActive' => (bool)$proActive,
|
||||
'proVersion' => $proVersion,
|
||||
'proVersion' => $reportedVersion,
|
||||
'proPayload' => $proPayload,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -592,6 +864,7 @@ public function installProBundle(): void
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
'footerHtml' => '',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -731,21 +1004,22 @@ public function installProBundle(): void
|
||||
|
||||
$merged['onlyoffice'] = $oo;
|
||||
}
|
||||
// Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
|
||||
if (isset($data['branding']) && is_array($data['branding'])) {
|
||||
if (!isset($merged['branding']) || !is_array($merged['branding'])) {
|
||||
$merged['branding'] = [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
];
|
||||
}
|
||||
foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark'] as $key) {
|
||||
if (array_key_exists($key, $data['branding'])) {
|
||||
$merged['branding'][$key] = (string)$data['branding'][$key];
|
||||
}
|
||||
}
|
||||
// Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
|
||||
if (isset($data['branding']) && is_array($data['branding'])) {
|
||||
if (!isset($merged['branding']) || !is_array($merged['branding'])) {
|
||||
$merged['branding'] = [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
'footerHtml' => '',
|
||||
];
|
||||
}
|
||||
foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark', 'footerHtml'] as $key) {
|
||||
if (array_key_exists($key, $data['branding'])) {
|
||||
$merged['branding'][$key] = (string)$data['branding'][$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = AdminModel::updateConfig($merged);
|
||||
if (isset($result['error'])) {
|
||||
|
||||
@@ -643,25 +643,137 @@ public function deleteFiles()
|
||||
} finally { $this->_jsonEnd(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a file with proper HTTP Range support so HTML5 video/audio can seek.
|
||||
*
|
||||
* @param string $fullPath Absolute filesystem path
|
||||
* @param string $downloadName Name shown in Content-Disposition
|
||||
* @param string $mimeType MIME type (from FileModel::getDownloadInfo)
|
||||
* @param bool $inline true => inline, false => attachment
|
||||
*/
|
||||
private function streamFileWithRange(string $fullPath, string $downloadName, string $mimeType, bool $inline): void
|
||||
{
|
||||
if (!is_file($fullPath) || !is_readable($fullPath)) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'File not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$size = (int)@filesize($fullPath);
|
||||
$start = 0;
|
||||
$end = $size > 0 ? $size - 1 : 0;
|
||||
|
||||
if ($size < 0) {
|
||||
$size = 0;
|
||||
$end = 0;
|
||||
}
|
||||
|
||||
// Close session + disable output buffering for streaming
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_write_close();
|
||||
}
|
||||
if (function_exists('apache_setenv')) {
|
||||
@apache_setenv('no-gzip', '1');
|
||||
}
|
||||
@ini_set('zlib.output_compression', '0');
|
||||
@ini_set('output_buffering', 'off');
|
||||
while (ob_get_level() > 0) {
|
||||
@ob_end_clean();
|
||||
}
|
||||
|
||||
$disposition = $inline ? 'inline' : 'attachment';
|
||||
$mime = $mimeType ?: 'application/octet-stream';
|
||||
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Accept-Ranges: bytes');
|
||||
header("Content-Type: {$mime}");
|
||||
header("Content-Disposition: {$disposition}; filename=\"" . basename($downloadName) . "\"");
|
||||
|
||||
// Handle HTTP Range header (single range)
|
||||
$length = $size;
|
||||
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=\s*(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)) {
|
||||
if ($m[1] !== '') {
|
||||
$start = (int)$m[1];
|
||||
}
|
||||
if ($m[2] !== '') {
|
||||
$end = (int)$m[2];
|
||||
}
|
||||
|
||||
// clamp to file size
|
||||
if ($start < 0) $start = 0;
|
||||
if ($end < $start) $end = $start;
|
||||
if ($end >= $size) $end = $size - 1;
|
||||
|
||||
$length = $end - $start + 1;
|
||||
|
||||
http_response_code(206);
|
||||
header("Content-Range: bytes {$start}-{$end}/{$size}");
|
||||
header("Content-Length: {$length}");
|
||||
} else {
|
||||
// no range => full file
|
||||
http_response_code(200);
|
||||
if ($size > 0) {
|
||||
header("Content-Length: {$size}");
|
||||
}
|
||||
}
|
||||
|
||||
$fp = @fopen($fullPath, 'rb');
|
||||
if ($fp === false) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'Unable to open file.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($start > 0) {
|
||||
@fseek($fp, $start);
|
||||
}
|
||||
|
||||
$bytesToSend = $length;
|
||||
$chunkSize = 8192;
|
||||
|
||||
while ($bytesToSend > 0 && !feof($fp)) {
|
||||
$readSize = ($bytesToSend > $chunkSize) ? $chunkSize : $bytesToSend;
|
||||
$buffer = fread($fp, $readSize);
|
||||
if ($buffer === false) {
|
||||
break;
|
||||
}
|
||||
echo $buffer;
|
||||
flush();
|
||||
$bytesToSend -= strlen($buffer);
|
||||
|
||||
if (connection_aborted()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function downloadFile()
|
||||
{
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = isset($_GET['file']) ? basename($_GET['file']) : '';
|
||||
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
|
||||
$file = isset($_GET['file']) ? basename((string)$_GET['file']) : '';
|
||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||
$inlineParam = isset($_GET['inline']) && (string)$_GET['inline'] === '1';
|
||||
|
||||
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Invalid file name."]);
|
||||
exit;
|
||||
}
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
@@ -681,6 +793,7 @@ public function deleteFiles()
|
||||
|
||||
if (!$fullView && !$ownGrant) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Forbidden: no view access to this folder."]);
|
||||
exit;
|
||||
}
|
||||
@@ -690,6 +803,7 @@ public function deleteFiles()
|
||||
$meta = $this->loadFolderMetadata($folder);
|
||||
if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
|
||||
exit;
|
||||
}
|
||||
@@ -697,25 +811,25 @@ public function deleteFiles()
|
||||
|
||||
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
|
||||
if (isset($downloadInfo['error'])) {
|
||||
http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400);
|
||||
http_response_code(in_array($downloadInfo['error'], ["File not found.", "Access forbidden."]) ? 404 : 400);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => $downloadInfo['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$realFilePath = $downloadInfo['filePath'];
|
||||
$mimeType = $downloadInfo['mimeType'];
|
||||
header("Content-Type: " . $mimeType);
|
||||
|
||||
// Decide inline vs attachment:
|
||||
// - if ?inline=1 => always inline (used by filePreview.js)
|
||||
// - else keep your old behavior: images inline, everything else attachment
|
||||
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
||||
$inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico'];
|
||||
if (in_array($ext, $inlineImageTypes, true)) {
|
||||
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
|
||||
} else {
|
||||
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
|
||||
}
|
||||
header('Content-Length: ' . filesize($realFilePath));
|
||||
readfile($realFilePath);
|
||||
exit;
|
||||
|
||||
$inline = $inlineParam || in_array($ext, $inlineImageTypes, true);
|
||||
|
||||
// Stream with proper Range support for video/audio seeking
|
||||
$this->streamFileWithRange($realFilePath, basename($realFilePath), $mimeType, $inline);
|
||||
}
|
||||
|
||||
public function zipStatus()
|
||||
|
||||
251
src/controllers/PortalController.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
// src/controllers/PortalController.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.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, // stored flag (legacy name)
|
||||
* 'allowDownload' => bool, // stored flag
|
||||
* 'expiresAt' => string,
|
||||
* 'title' => string,
|
||||
* 'introText' => string,
|
||||
* 'requireForm' => bool,
|
||||
* 'brandColor' => string,
|
||||
* 'footerText' => string,
|
||||
* 'formDefaults' => array,
|
||||
* 'formRequired' => array,
|
||||
* 'formLabels' => array,
|
||||
* 'formVisible' => array,
|
||||
* 'logoFile' => string,
|
||||
* 'logoUrl' => string,
|
||||
* 'uploadMaxSizeMb' => int,
|
||||
* 'uploadExtWhitelist' => string,
|
||||
* 'uploadMaxPerDay' => int,
|
||||
* 'showThankYou' => bool,
|
||||
* 'thankYouText' => string,
|
||||
* 'canUpload' => bool, // ACL + portal flags
|
||||
* 'canDownload' => bool, // ACL + portal flags
|
||||
* ]
|
||||
*/
|
||||
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];
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Normalize upload/download flags (old + new)
|
||||
// ─────────────────────────────────────────────
|
||||
//
|
||||
// Storage:
|
||||
// - OLD (no allowDownload):
|
||||
// uploadOnly=true => upload yes, download no
|
||||
// uploadOnly=false => upload yes, download yes
|
||||
//
|
||||
// - NEW:
|
||||
// "Allow upload" checkbox is stored as uploadOnly (🤮 name, but we keep it)
|
||||
// "Allow download" checkbox is stored as allowDownload
|
||||
//
|
||||
// Normalized flags we want here:
|
||||
// - $allowUpload (bool)
|
||||
// - $allowDownload (bool)
|
||||
$hasAllowDownload = array_key_exists('allowDownload', $p);
|
||||
$rawUploadOnly = !empty($p['uploadOnly']); // legacy name
|
||||
$rawAllowDownload = $hasAllowDownload ? !empty($p['allowDownload']) : null;
|
||||
|
||||
if ($hasAllowDownload) {
|
||||
// New JSON – trust both checkboxes exactly
|
||||
$allowUpload = $rawUploadOnly; // "Allow upload" in UI
|
||||
$allowDownload = (bool)$rawAllowDownload;
|
||||
} else {
|
||||
// Legacy JSON – no separate allowDownload
|
||||
// uploadOnly=true => upload yes, download no
|
||||
// uploadOnly=false => upload yes, download yes
|
||||
$allowUpload = true;
|
||||
$allowDownload = !$rawUploadOnly;
|
||||
}
|
||||
|
||||
$label = trim((string)($p['label'] ?? $slug));
|
||||
$folder = trim((string)($p['folder'] ?? ''));
|
||||
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
||||
|
||||
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
||||
|
||||
// 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'] ?? ''));
|
||||
|
||||
// Defaults / required
|
||||
$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']),
|
||||
];
|
||||
|
||||
// Optional formLabels
|
||||
$fl = isset($p['formLabels']) && is_array($p['formLabels'])
|
||||
? $p['formLabels']
|
||||
: [];
|
||||
|
||||
$formLabels = [
|
||||
'name' => trim((string)($fl['name'] ?? 'Name')),
|
||||
'email' => trim((string)($fl['email'] ?? 'Email')),
|
||||
'reference' => trim((string)($fl['reference'] ?? 'Reference / Case / Order #')),
|
||||
'notes' => trim((string)($fl['notes'] ?? 'Notes')),
|
||||
];
|
||||
|
||||
// Optional visibility
|
||||
$fv = isset($p['formVisible']) && is_array($p['formVisible'])
|
||||
? $p['formVisible']
|
||||
: [];
|
||||
|
||||
$formVisible = [
|
||||
'name' => !array_key_exists('name', $fv) || !empty($fv['name']),
|
||||
'email' => !array_key_exists('email', $fv) || !empty($fv['email']),
|
||||
'reference' => !array_key_exists('reference', $fv) || !empty($fv['reference']),
|
||||
'notes' => !array_key_exists('notes', $fv) || !empty($fv['notes']),
|
||||
];
|
||||
|
||||
// Optional per-portal logo
|
||||
$logoFile = trim((string)($p['logoFile'] ?? ''));
|
||||
$logoUrl = trim((string)($p['logoUrl'] ?? ''));
|
||||
|
||||
// Upload rules / thank-you behavior
|
||||
$uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0;
|
||||
$uploadExtWhitelist = trim((string)($p['uploadExtWhitelist'] ?? ''));
|
||||
$uploadMaxPerDay = isset($p['uploadMaxPerDay']) ? (int)$p['uploadMaxPerDay'] : 0;
|
||||
$showThankYou = !empty($p['showThankYou']);
|
||||
$thankYouText = trim((string)($p['thankYouText'] ?? ''));
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────
|
||||
// Capability flags (portal + ACL)
|
||||
// ──────────────────────────────
|
||||
//
|
||||
// Base from portal config:
|
||||
$canUpload = (bool)$allowUpload;
|
||||
$canDownload = (bool)$allowDownload;
|
||||
|
||||
// Refine with ACL for the current logged-in user (if any)
|
||||
$user = (string)($_SESSION['username'] ?? '');
|
||||
$perms = [
|
||||
'role' => $_SESSION['role'] ?? null,
|
||||
'admin' => $_SESSION['admin'] ?? null,
|
||||
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||
];
|
||||
|
||||
if ($user !== '') {
|
||||
// Upload: must also pass folder-level ACL
|
||||
if ($canUpload && !ACL::canUpload($user, $perms, $folder)) {
|
||||
$canUpload = false;
|
||||
}
|
||||
|
||||
// Download: require read or read_own
|
||||
if (
|
||||
$canDownload
|
||||
&& !ACL::canRead($user, $perms, $folder)
|
||||
&& !ACL::canReadOwn($user, $perms, $folder)
|
||||
) {
|
||||
$canDownload = false;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'slug' => $slug,
|
||||
'label' => $label,
|
||||
'folder' => $folder,
|
||||
'clientEmail' => $clientEmail,
|
||||
// Store flags as-is so old code / JSON stay compatible
|
||||
'uploadOnly' => (bool)$rawUploadOnly,
|
||||
'allowDownload' => $hasAllowDownload
|
||||
? (bool)$rawAllowDownload
|
||||
: $allowDownload,
|
||||
'expiresAt' => $expiresAt,
|
||||
'title' => $title,
|
||||
'introText' => $introText,
|
||||
'requireForm' => $requireForm,
|
||||
'brandColor' => $brandColor,
|
||||
'footerText' => $footerText,
|
||||
'formDefaults' => $formDefaults,
|
||||
'formRequired' => $formRequired,
|
||||
'formLabels' => $formLabels,
|
||||
'formVisible' => $formVisible,
|
||||
'logoFile' => $logoFile,
|
||||
'logoUrl' => $logoUrl,
|
||||
'uploadMaxSizeMb' => $uploadMaxSizeMb,
|
||||
'uploadExtWhitelist' => $uploadExtWhitelist,
|
||||
'uploadMaxPerDay' => $uploadMaxPerDay,
|
||||
'showThankYou' => $showThankYou,
|
||||
'thankYouText' => $thankYouText,
|
||||
// New ACL-aware caps for portal.js
|
||||
'canUpload' => $canUpload,
|
||||
'canDownload' => $canDownload,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
@@ -745,6 +797,90 @@ class UserController
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a logo for a specific client portal (Pro-only; admin, CSRF).
|
||||
* Stores the file in UPLOAD_DIR/profile_pics and returns filename + URL.
|
||||
*/
|
||||
public function uploadPortalLogo(): void
|
||||
{
|
||||
self::jsonHeaders();
|
||||
|
||||
// Auth, admin & CSRF
|
||||
self::requireAuth();
|
||||
self::requireAdmin();
|
||||
self::requireCsrf();
|
||||
|
||||
if (empty($_FILES['portal_logo']) || $_FILES['portal_logo']['error'] !== UPLOAD_ERR_OK) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = $_FILES['portal_logo'];
|
||||
|
||||
// Optional: which portal (used only for filename prefix)
|
||||
$slugRaw = isset($_POST['slug']) ? (string)$_POST['slug'] : '';
|
||||
$slug = preg_replace('/[^a-zA-Z0-9_\-]/', '', $slugRaw) ?: 'portal';
|
||||
|
||||
// Validate MIME & size (same rules as uploadPicture / uploadBrandLogo)
|
||||
$allowed = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
];
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!isset($allowed[$mime])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid file type']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($file['size'] > 2 * 1024 * 1024) { // 2MB
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'File too large']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Destination: reuse profile_pics directory
|
||||
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
|
||||
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ext = $allowed[$mime];
|
||||
$filename = 'portal_' . $slug . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
$dest = $uploadDir . '/' . $filename;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Build a web path similar to uploadBrandLogo
|
||||
$fsPath = $uploadDir . '/' . $filename;
|
||||
|
||||
$root = rtrim(PROJECT_ROOT, '/\\');
|
||||
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
|
||||
|
||||
if ($url === '' || $url[0] !== '/') {
|
||||
$url = '/' . ltrim($url, '/\\');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'fileName' => $filename,
|
||||
'url' => $url,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function siteConfig(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
178
src/lib/ACL.php
@@ -227,6 +227,166 @@ class ACL
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load Pro user groups from FR_PRO_BUNDLE_DIR/users/pro/groups.json.
|
||||
* Returns a map: groupName => ['name','label','members'=>[],'grants'=>[]]
|
||||
* When Pro is inactive or no file exists, returns an empty array.
|
||||
*/
|
||||
private static function loadGroupData(): array
|
||||
{
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) return [];
|
||||
if (!defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) return [];
|
||||
|
||||
static $loaded = false;
|
||||
static $cache = [];
|
||||
static $mtime = 0;
|
||||
|
||||
$base = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\");
|
||||
if ($base === '') return [];
|
||||
|
||||
$file = $base . DIRECTORY_SEPARATOR . 'groups.json';
|
||||
$mt = @filemtime($file) ?: 0;
|
||||
|
||||
if ($loaded && $mtime === $mt) {
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$loaded = true;
|
||||
$mtime = $mt;
|
||||
if (!$mt || !is_file($file)) {
|
||||
$cache = [];
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($file);
|
||||
if ($raw === false || $raw === '') {
|
||||
$cache = [];
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
$cache = [];
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$groups = isset($data['groups']) && is_array($data['groups']) ? $data['groups'] : $data;
|
||||
$norm = [];
|
||||
|
||||
foreach ($groups as $key => $g) {
|
||||
if (!is_array($g)) continue;
|
||||
$name = isset($g['name']) ? (string)$g['name'] : (string)$key;
|
||||
$name = trim($name);
|
||||
if ($name === '') continue;
|
||||
|
||||
$g['name'] = $name;
|
||||
$g['label'] = isset($g['label']) ? (string)$g['label'] : $name;
|
||||
|
||||
if (!isset($g['members']) || !is_array($g['members'])) {
|
||||
$g['members'] = [];
|
||||
} else {
|
||||
$g['members'] = array_values(array_unique(array_map('strval', $g['members'])));
|
||||
}
|
||||
|
||||
if (!isset($g['grants']) || !is_array($g['grants'])) {
|
||||
$g['grants'] = [];
|
||||
}
|
||||
|
||||
$norm[$name] = $g;
|
||||
}
|
||||
|
||||
$cache = $norm;
|
||||
return $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a group grants record for a single folder to a capability bucket.
|
||||
* Supports both internal bucket keys and the UI-style keys: view, viewOwn,
|
||||
* manage, shareFile, shareFolder.
|
||||
*/
|
||||
private static function groupGrantsCap(array $grants, string $capKey): bool
|
||||
{
|
||||
// Direct match (owners, read, write, share, read_own, create, upload, edit, rename, copy, move, delete, extract, share_file, share_folder)
|
||||
if (array_key_exists($capKey, $grants) && $grants[$capKey] === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch ($capKey) {
|
||||
case 'read':
|
||||
return !empty($grants['view']);
|
||||
case 'read_own':
|
||||
// Full view always implies own
|
||||
if (!empty($grants['view'])) return true;
|
||||
return !empty($grants['viewOwn']);
|
||||
case 'share_file':
|
||||
if (!empty($grants['share_file'])) return true;
|
||||
return !empty($grants['shareFile']);
|
||||
case 'share_folder':
|
||||
if (!empty($grants['share_folder'])) return true;
|
||||
return !empty($grants['shareFolder']);
|
||||
case 'write':
|
||||
case 'create':
|
||||
case 'upload':
|
||||
case 'edit':
|
||||
case 'rename':
|
||||
case 'copy':
|
||||
case 'move':
|
||||
case 'delete':
|
||||
case 'extract':
|
||||
if (!empty($grants[$capKey])) return true;
|
||||
// Group "manage" implies all write-ish caps
|
||||
return !empty($grants['manage']);
|
||||
case 'share':
|
||||
if (!empty($grants['share'])) return true;
|
||||
// Manage can optionally imply share; this keeps UI simple
|
||||
return !empty($grants['manage']);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether any Pro group the user belongs to grants this cap for folder.
|
||||
* Groups are additive only; they never remove access.
|
||||
*/
|
||||
private static function groupHasGrant(string $user, string $folder, string $capKey): bool
|
||||
{
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) return false;
|
||||
$user = (string)$user;
|
||||
if ($user === '') return false;
|
||||
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if ($folder === '') $folder = 'root';
|
||||
|
||||
$groups = self::loadGroupData();
|
||||
if (!$groups) return false;
|
||||
|
||||
foreach ($groups as $g) {
|
||||
if (!is_array($g)) continue;
|
||||
|
||||
$members = $g['members'] ?? [];
|
||||
$isMember = false;
|
||||
if (is_array($members)) {
|
||||
foreach ($members as $m) {
|
||||
if (strcasecmp((string)$m, $user) === 0) {
|
||||
$isMember = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$isMember) continue;
|
||||
|
||||
$folderGrants = $g['grants'][$folder] ?? null;
|
||||
if (!is_array($folderGrants)) continue;
|
||||
|
||||
if (self::groupGrantsCap($folderGrants, $capKey)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
private static function save(array $acl): bool
|
||||
{
|
||||
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
|
||||
@@ -286,8 +446,20 @@ class ACL
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$capKey = ($cap === 'owner') ? 'owners' : $cap;
|
||||
$arr = self::listFor($folder, $capKey);
|
||||
foreach ($arr as $u) if (strcasecmp((string)$u, $user) === 0) return true;
|
||||
|
||||
// 1) Core per-folder ACL buckets (folder_acl.json)
|
||||
$arr = self::listFor($folder, $capKey);
|
||||
foreach ($arr as $u) {
|
||||
if (strcasecmp((string)$u, $user) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Pro user groups (if enabled) – additive only
|
||||
if (self::groupHasGrant($user, $folder, $capKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -620,4 +792,4 @@ class ACL
|
||||
// require full view too
|
||||
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'read');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,17 +110,19 @@ private static function sanitizeLogoUrl($url): string
|
||||
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||
],
|
||||
'branding' => [
|
||||
'customLogoUrl' => self::sanitizeLogoUrl(
|
||||
$config['branding']['customLogoUrl'] ?? ''
|
||||
),
|
||||
'headerBgLight' => self::sanitizeColorHex(
|
||||
$config['branding']['headerBgLight'] ?? ''
|
||||
),
|
||||
'headerBgDark' => self::sanitizeColorHex(
|
||||
$config['branding']['headerBgDark'] ?? ''
|
||||
),
|
||||
],
|
||||
'branding' => [
|
||||
'customLogoUrl' => self::sanitizeLogoUrl(
|
||||
$config['branding']['customLogoUrl'] ?? ''
|
||||
),
|
||||
'headerBgLight' => self::sanitizeColorHex(
|
||||
$config['branding']['headerBgLight'] ?? ''
|
||||
),
|
||||
'headerBgDark' => self::sanitizeColorHex(
|
||||
$config['branding']['headerBgDark'] ?? ''
|
||||
),
|
||||
'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''),
|
||||
],
|
||||
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
|
||||
];
|
||||
|
||||
// NEW: include ONLYOFFICE minimal public flag
|
||||
@@ -136,16 +138,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
|
||||
@@ -259,29 +262,31 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||
$configUpdate['onlyoffice'] = $norm;
|
||||
}
|
||||
|
||||
// Branding (Pro-only). Normalize and only persist when Pro is active.
|
||||
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
|
||||
$configUpdate['branding'] = [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
];
|
||||
} else {
|
||||
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
|
||||
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
|
||||
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
|
||||
|
||||
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
|
||||
$configUpdate['branding']['customLogoUrl'] = $logo;
|
||||
$configUpdate['branding']['headerBgLight'] = $light;
|
||||
$configUpdate['branding']['headerBgDark'] = $dark;
|
||||
} else {
|
||||
// Free mode: always clear branding customizations
|
||||
$configUpdate['branding']['customLogoUrl'] = '';
|
||||
$configUpdate['branding']['headerBgLight'] = '';
|
||||
$configUpdate['branding']['headerBgDark'] = '';
|
||||
}
|
||||
}
|
||||
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
|
||||
$configUpdate['branding'] = [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
'footerHtml' => '',
|
||||
];
|
||||
} else {
|
||||
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
|
||||
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
|
||||
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
|
||||
$footer = trim((string)($configUpdate['branding']['footerHtml'] ?? ''));
|
||||
|
||||
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
|
||||
$configUpdate['branding']['customLogoUrl'] = $logo;
|
||||
$configUpdate['branding']['headerBgLight'] = $light;
|
||||
$configUpdate['branding']['headerBgDark'] = $dark;
|
||||
$configUpdate['branding']['footerHtml'] = $footer;
|
||||
} else {
|
||||
$configUpdate['branding']['customLogoUrl'] = '';
|
||||
$configUpdate['branding']['headerBgLight'] = '';
|
||||
$configUpdate['branding']['headerBgDark'] = '';
|
||||
$configUpdate['branding']['footerHtml'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Convert configuration to JSON.
|
||||
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
|
||||
@@ -442,6 +447,7 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
'footerHtml' => '',
|
||||
];
|
||||
} else {
|
||||
$config['branding']['customLogoUrl'] = self::sanitizeLogoUrl(
|
||||
@@ -484,6 +490,7 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
'footerHtml' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
723
src/models/DiskUsageModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -503,13 +503,13 @@ class FileModel {
|
||||
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||
return ["error" => "Invalid file name."];
|
||||
}
|
||||
|
||||
|
||||
// Determine the real upload directory.
|
||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||
if ($uploadDirReal === false) {
|
||||
return ["error" => "Server misconfiguration."];
|
||||
}
|
||||
|
||||
|
||||
// Determine directory based on folder.
|
||||
if (strtolower($folder) === 'root' || trim($folder) === '') {
|
||||
$directory = $uploadDirReal;
|
||||
@@ -524,11 +524,11 @@ class FileModel {
|
||||
return ["error" => "Invalid folder path."];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Build the file path.
|
||||
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
$realFilePath = realpath($filePath);
|
||||
|
||||
|
||||
// Ensure the file exists and is within the allowed directory.
|
||||
if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) {
|
||||
return ["error" => "Access forbidden."];
|
||||
@@ -536,19 +536,86 @@ class FileModel {
|
||||
if (!file_exists($realFilePath)) {
|
||||
return ["error" => "File not found."];
|
||||
}
|
||||
|
||||
|
||||
// Get the MIME type with safe fallback.
|
||||
$mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null;
|
||||
if (!$mimeType) {
|
||||
$mimeType = 'application/octet-stream';
|
||||
}
|
||||
|
||||
|
||||
// OPTIONAL: normalize SVG MIME
|
||||
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
||||
if ($ext === 'svg') {
|
||||
$mimeType = 'image/svg+xml';
|
||||
}
|
||||
|
||||
return [
|
||||
"filePath" => $realFilePath,
|
||||
"mimeType" => $mimeType
|
||||
];
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -11,88 +11,137 @@ 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);
|
||||
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,
|
||||
];
|
||||
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
|
||||
$canViewFolder = ACL::isAdmin($perms)
|
||||
|| ACL::canRead($user, $perms, $folder)
|
||||
|| ACL::canReadOwn($user, $perms, $folder);
|
||||
if (!$canViewFolder) {
|
||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||
}
|
||||
|
||||
// NEW: distinguish full read vs own-only for this folder
|
||||
$hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder);
|
||||
// if !$hasFullRead but $canViewFolder is true, they’re effectively "view own" only
|
||||
|
||||
$base = realpath((string)UPLOAD_DIR);
|
||||
if ($base === false) {
|
||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||
}
|
||||
|
||||
// Resolve target dir + ACL-relative prefix
|
||||
if ($folder === 'root') {
|
||||
$dir = $base;
|
||||
$relPrefix = '';
|
||||
} else {
|
||||
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
|
||||
foreach ($parts as $seg) {
|
||||
if (!self::isSafeSegment($seg)) {
|
||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||
}
|
||||
}
|
||||
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||
$dir = self::safeReal($base, $guess);
|
||||
if ($dir === null || !is_dir($dir)) {
|
||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||
}
|
||||
$relPrefix = implode('/', $parts);
|
||||
}
|
||||
|
||||
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
|
||||
$SKIP = ['trash', 'profile_pics'];
|
||||
|
||||
$entries = @scandir($dir);
|
||||
if ($entries === false) {
|
||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||
}
|
||||
|
||||
$folderCount = 0;
|
||||
$fileCount = 0;
|
||||
$totalBytes = 0;
|
||||
|
||||
// NEW: stats for created / modified
|
||||
$earliestUploaded = null; // min mtime
|
||||
$latestMtime = null; // max mtime
|
||||
|
||||
$MAX_SCAN = 4000;
|
||||
$scanned = 0;
|
||||
|
||||
foreach ($entries as $name) {
|
||||
if (++$scanned > $MAX_SCAN) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($name === '.' || $name === '..') continue;
|
||||
if ($name[0] === '.') continue;
|
||||
if (in_array($name, $IGNORE, true)) continue;
|
||||
if (in_array(strtolower($name), $SKIP, true)) continue;
|
||||
if (!self::isSafeSegment($name)) continue;
|
||||
|
||||
$abs = $dir . DIRECTORY_SEPARATOR . $name;
|
||||
|
||||
if (@is_dir($abs)) {
|
||||
if (@is_link($abs)) {
|
||||
$safe = self::safeReal($base, $abs);
|
||||
if ($safe === null || !is_dir($safe)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
|
||||
if (
|
||||
ACL::isAdmin($perms)
|
||||
|| ACL::canRead($user, $perms, $childRel)
|
||||
|| ACL::canReadOwn($user, $perms, $childRel)
|
||||
) {
|
||||
$folderCount++;
|
||||
}
|
||||
} elseif (@is_file($abs)) {
|
||||
// Only count files if the user has full read on *this* folder.
|
||||
// If they’re view_own-only here, don’t leak or mis-report counts.
|
||||
if (!$hasFullRead) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileCount++;
|
||||
$sz = @filesize($abs);
|
||||
if (is_int($sz) && $sz > 0) {
|
||||
$totalBytes += $sz;
|
||||
}
|
||||
|
||||
// NEW: track earliest / latest mtime from visible files
|
||||
$mt = @filemtime($abs);
|
||||
if (is_int($mt) && $mt > 0) {
|
||||
if ($earliestUploaded === null || $mt < $earliestUploaded) {
|
||||
$earliestUploaded = $mt;
|
||||
}
|
||||
if ($latestMtime === null || $mt > $latestMtime) {
|
||||
$latestMtime = $mt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = [
|
||||
'folders' => $folderCount,
|
||||
'files' => $fileCount,
|
||||
'bytes' => $totalBytes,
|
||||
];
|
||||
|
||||
// Only include when we actually saw at least one readable file
|
||||
if ($earliestUploaded !== null) {
|
||||
$result['earliest_uploaded'] = date(DATE_TIME_FORMAT, $earliestUploaded);
|
||||
}
|
||||
if ($latestMtime !== null) {
|
||||
$result['latest_mtime'] = date(DATE_TIME_FORMAT, $latestMtime);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/* Helpers (private) */
|
||||
private static function isSafeSegment(string $name): bool
|
||||
{
|
||||
@@ -459,6 +508,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.
|
||||
|
||||
17
start.sh
@@ -72,23 +72,6 @@ for d in uploads users metadata; do
|
||||
chmod 775 "${tgt}"
|
||||
done
|
||||
|
||||
# 2.4) Sync FileRise Pro public endpoints from persistent bundle
|
||||
BUNDLE_PRO_PUBLIC="/var/www/users/pro/public/api/pro"
|
||||
LIVE_PRO_PUBLIC="/var/www/public/api/pro"
|
||||
|
||||
if [ -d "${BUNDLE_PRO_PUBLIC}" ]; then
|
||||
echo "[startup] Syncing FileRise Pro public endpoints..."
|
||||
mkdir -p "${LIVE_PRO_PUBLIC}"
|
||||
|
||||
# Copy files from bundle to live api/pro (overwrite for upgrades)
|
||||
cp -R "${BUNDLE_PRO_PUBLIC}/." "${LIVE_PRO_PUBLIC}/" || echo "[startup] Pro sync copy failed (continuing)"
|
||||
|
||||
# Normalize ownership/permissions
|
||||
chown -R www-data:www-data "${LIVE_PRO_PUBLIC}" || echo "[startup] chown api/pro failed (continuing)"
|
||||
find "${LIVE_PRO_PUBLIC}" -type d -exec chmod 755 {} \; 2>/dev/null || true
|
||||
find "${LIVE_PRO_PUBLIC}" -type f -exec chmod 644 {} \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 3) Ensure PHP conf dir & set upload limits
|
||||
mkdir -p /etc/php/8.3/apache2/conf.d
|
||||
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||
|
||||