Compare commits

...

21 Commits

Author SHA1 Message Date
github-actions[bot]
e1b20a9f1d chore(release): set APP_VERSION to v1.9.11 [skip ci] 2025-11-18 20:07:36 +00:00
Ryan
0ec8103fbf release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68) 2025-11-18 15:07:27 -05:00
github-actions[bot]
3b1ebdd77f chore(release): set APP_VERSION to v1.9.10 [skip ci] 2025-11-18 07:22:03 +00:00
Ryan
3726e2423d release(v1.9.10): add Pro bundle installer and admin panel polish 2025-11-18 02:21:52 -05:00
github-actions[bot]
5613710411 chore(release): set APP_VERSION to v1.9.9 [skip ci] 2025-11-17 02:31:19 +00:00
Ryan
08f7ffccbc release(v1.9.9): fix(branding): sanitize custom logo URL 2025-11-16 21:31:08 -05:00
Ryan
ad1d41fad8 docs(readme): simplify core README and highlight Pro edition 2025-11-16 21:22:11 -05:00
github-actions[bot]
99662cd2f2 chore(release): set APP_VERSION to v1.9.8 [skip ci] 2025-11-17 02:11:15 +00:00
Ryan
060a548af4 release(v1.9.8): feat(pro): wire core to Pro licensing + branding hooks 2025-11-16 21:11:06 -05:00
Ryan
9880adb417 docs(readme): replace old video demo section with latest v1.9.7 screenshot 2025-11-14 20:29:37 -05:00
github-actions[bot]
a56641e81c chore(release): set APP_VERSION to v1.9.7 [skip ci] 2025-11-15 01:11:29 +00:00
Ryan
3b636f69d8 release(v1.9.7): harden client path guard and refine header/folder strip CSS 2025-11-14 20:11:19 -05:00
github-actions[bot]
930ed954ec chore(release): set APP_VERSION to v1.9.6 [skip ci] 2025-11-14 10:00:07 +00:00
Ryan
402f590163 release(v1.9.6): hardened resumable uploads, menu/tag UI polish and hidden temp folders (closes #67) 2025-11-14 04:59:58 -05:00
github-actions[bot]
ef47ad2b52 chore(release): set APP_VERSION to v1.9.5 [skip ci] 2025-11-13 10:32:48 +00:00
Ryan
8cdff954d5 release(v1.9.5): harden folder tree DOM, add a11y to “Load more”, and guard folder paths 2025-11-13 05:32:33 -05:00
github-actions[bot]
01cfa597b9 chore(release): set APP_VERSION to v1.9.4 [skip ci] 2025-11-13 10:06:34 +00:00
Ryan
f5e42a2e81 release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more”
- Lazy folder tree via /api/folder/listChildren.php with cursor pagination
- ACL-safe chevrons using hasSubfolders from server; no file-count leaks
- BFS smart initial folder selection + respect lastOpenedFolder
- Locked nodes are expandable but not selectable
- “Load more” UX (light & dark) for huge directories

Closes #66
2025-11-13 05:06:24 -05:00
Ryan
f1dcc0df24 ci(release): run Release on workflow_run; fix(css): remove folder SVG lip-highlight stroke 2025-11-11 01:04:31 -05:00
github-actions[bot]
ba9ead666d chore(release): set APP_VERSION to v1.9.3 [skip ci] 2025-11-11 05:09:24 +00:00
Ryan
dbdf760d4d release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release 2025-11-11 00:09:15 -05:00
36 changed files with 8279 additions and 5456 deletions

View File

@@ -2,10 +2,10 @@
name: Release on version.js update
on:
push:
branches: ["master"]
paths:
- public/js/version.js
workflow_run:
workflows: ["Bump version and sync Changelog to Docker Repo"]
types: [completed]
branches: [master]
workflow_dispatch:
inputs:
ref:
@@ -21,15 +21,10 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
# Only run on:
# - push (master + version.js path filter already enforces that)
# - manual dispatch
if: |
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch'
# Duplicate safety; also step "Skip if tag exists" will no-op if already released.
concurrency:
group: release-${{ github.event_name }}-${{ github.run_id }}
cancel-in-progress: false
@@ -46,17 +41,14 @@ jobs:
else
REF_IN="master"
fi
# Resolve to a commit sha (allow branches or shas)
if git ls-remote --exit-code --heads https://github.com/${{ github.repository }}.git "$REF_IN" >/dev/null 2>&1; then
REF="$REF_IN"
else
# Accept SHAs too; well let checkout validate
REF="$REF_IN"
fi
else
REF="${{ github.sha }}"
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
echo "Using ref=$REF"
@@ -75,7 +67,6 @@ jobs:
if [[ -n "${{ github.event.inputs.version || '' }}" ]]; then
VER="${{ github.event.inputs.version }}"
else
# Parse APP_VERSION from public/js/version.js (expects vX.Y.Z)
if [[ ! -f public/js/version.js ]]; then
echo "public/js/version.js not found; cannot auto-detect version." >&2
exit 1
@@ -86,7 +77,6 @@ jobs:
exit 1
fi
fi
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "Detected version: $VER"
@@ -124,20 +114,67 @@ jobs:
./ staging/
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/staging"
- name: Verify placeholders removed
# --- PHP + Composer for vendor/ (production) ---
- name: Setup PHP
if: steps.tagcheck.outputs.exists == 'false'
id: php
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
extensions: mbstring, json, curl, dom, fileinfo, openssl, zip
coverage: none
ini-values: memory_limit=-1
- name: Cache Composer downloads
if: steps.tagcheck.outputs.exists == 'false'
uses: actions/cache@v4
with:
path: |
~/.composer/cache
~/.cache/composer
key: composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-
- name: Install PHP dependencies into staging
if: steps.tagcheck.outputs.exists == 'false'
env:
COMPOSER_MEMORY_LIMIT: -1
shell: bash
run: |
set -euo pipefail
pushd staging >/dev/null
if [[ -f composer.json ]]; then
composer install \
--no-dev \
--prefer-dist \
--no-interaction \
--no-progress \
--optimize-autoloader \
--classmap-authoritative
test -f vendor/autoload.php || (echo "Composer install did not produce vendor/autoload.php" >&2; exit 1)
else
echo "No composer.json in staging; skipping vendor install."
fi
popd >/dev/null
# --- end Composer ---
- name: Verify placeholders removed (skip vendor/)
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
set -euo pipefail
ROOT="$(pwd)/staging"
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
--exclude-dir=vendor --exclude-dir=vendor-bin \
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
echo "Unreplaced placeholders found in staging." >&2
exit 1
fi
echo "OK: No unreplaced placeholders."
- name: Zip artifact
- name: Zip artifact (includes vendor/)
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |

View File

@@ -1,5 +1,299 @@
# Changelog
## Changes 11/18/2025 (v1.9.11)
release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68)
- media: add proper HTTP Range support to /api/file/download.php so HTML5
video/audio can seek correctly across all browsers (Brave/Chrome/Android/Windows).
- media: avoid buffering the entire file in memory; stream from disk with
200/206 responses and Accept-Ranges for smoother playback and faster start times.
- media: keep video progress tracking, watched badges, and status chip behavior
unchanged but now compatible with the new streaming endpoint.
- ui: update the folder strip to be responsive:
- desktop: keep the existing "chip" layout with icon above name.
- mobile: switch to inline rows `[icon] [name]` with reduced whitespace.
- ui: add simple lazy-loading for the folder strip so only the first batch of
folders is rendered initially, with a "Load more…" button to append chunks for
very large folder sets (stays friendly with 100k+ folders).
- misc: small CSS tidy-up around the folder strip classes to remove duplicates
and keep mobile/desktop behavior clearly separated.
---
## Changes 11/18/2025 (v1.9.10)
release(v1.9.10): add Pro bundle installer and admin panel polish
- Add FileRise Pro section in admin panel with license management and bundle upload
- Persist Pro bundle under users/pro and sync public/api/pro endpoints on container startup
- Improve admin config API: Pro metadata, license file handling, hardened auth/CSRF helpers
- Update Pro badge/version UI with “update available” hint and link to filerise.net
- Change Pro bundle installer to always overwrite existing bundle files for clean upgrades
---
## Changes 11/16/2025 (v1.9.9)
release(v1.9.9): fix(branding): sanitize custom logo URL preview
- Sanitize branding.customLogoUrl on the server before writing siteConfig.json
- Allow only http/https or site-relative paths; strip invalid/sneaky values
- Update adminPanel.js live logo preview to set img src/alt safely
- Addresses CodeQL XSS warning while keeping Pro branding logo overrides working
---
## Changes 11/16/2025 (v1.9.8)
release(v1.9.8): feat(pro): wire core to Pro licensing + branding hooks
- Add Pro feature flags + bootstrap wiring
- Define FR_PRO_ACTIVE/FR_PRO_TYPE/FR_PRO_EMAIL/FR_PRO_VERSION/FR_PRO_LICENSE_FILE
in config.php and optionally require src/pro/bootstrap_pro.php.
- Expose a `pro` block from AdminController::getConfig() so the UI can show
license status, type, email, and bundle version without leaking the raw key.
- Implement license save endpoint
- Add AdminController::setLicense() and /api/admin/setLicense.php to accept a
FRP1 license string via JSON, validate basic shape, and persist it to
FR_PRO_LICENSE_FILE with strict 0600 permissions.
- Return structured JSON success/error responses for the admin UI.
- Extend admin config model with branding + safer validation
- Add `branding.customLogoUrl`, `branding.headerBgLight`, and
`branding.headerBgDark` fields to AdminModel defaults and updateConfig().
- Introduce AdminModel::sanitizeLogoUrl() to allow only site-relative /uploads
paths or http(s) URLs; reject absolute filesystem paths, data: URLs, and
javascript: URLs.
- Continue to validate ONLYOFFICE docsOrigin as http(s) only, keeping core
config hardening intact.
- New Pro-aware Admin Panel UI
- Rework User Management section to group:
- Add user / Remove user
- Folder Access (per-folder ACL)
- User Permissions (account-level flags)
- Add Pro-only actions with clear gating:
- “User groups” button (Pro)
- “Client upload portal” button with “Pro · Coming soon” pill
- Add “FileRise Pro” section:
- Show current Pro status (Free vs Active) + license metadata.
- Textarea for pasting license key, file upload helper, and “Save license”
action wired to /api/admin/setLicense.php.
- Optional “Copy current license” button when a license is present.
- Add “Sponsor / Donations” section with fixed GitHub Sponsors and Ko-fi URLs
and one-click copy/open buttons.
- Header branding controls (Pro)
- Add Header Logo + Header Colors controls under Header Settings, gated by
`config.pro.active`.
- Allow uploading a logo via /api/pro/uploadBrandLogo.php and auto-filling the
normalized /uploads path.
- Add live-preview helpers to update the header logo and header background
colors in the running UI after saving.
- Apply branding on app boot
- Update main.js to read branding config on load and apply:
- Custom header logo (or fallback to /assets/logo.svg).
- Light/dark header background colors via CSS variables.
- Keeps header consistent with saved branding across reloads and before
opening the admin panel.
- Styling + UX polish
- Add styles for new admin sections: collapsible headers, dark-mode aware
modal content, and refined folder access grid.
- Introduce .btn-pro-admin and .btn-pro-pill classes to render “Pro” and
“Pro · Coming soon” pills overlayed on buttons, matching the existing
header “Core/Pro” badge treatment.
- Minor spacing/typography tweaks in admin panel and ACL UI.
Note: Core code remains MIT-licensed; Pro functionality is enabled via optional
runtime hooks and separate closed-source bundle, without changing the core
license text.
---
## Changes 11/14/2025 (v1.9.7)
release(v1.9.7): harden client path guard and refine header/folder strip CSS
- Tighten isSafeFolderPath() to reject dot-prefixed/invalid segments (client-side defense-in-depth on folder paths).
- Rework header layout: consistent logo sizing, centered title, cleaner button alignment, and better small-screen stacking.
- Polish user dropdown and icon buttons: improved hover/focus states, dark-mode colors, and rounded menu corners.
- Update folder strip tiles: cap tile width, allow long folder names to wrap neatly, and fine-tune text/icon alignment.
- Tweak folder tree rows: better label wrapping, vertical alignment, and consistent SVG folder icon rendering.
- Small CSS cleanup and normalization (body, main wrapper, media modal/progress styles) without changing behavior.
---
## Changes 11/14/2025 (v1.9.6)
release(v1.9.6): hardened resumable uploads, menu/tag UI polish and hidden temp folders (closes #67)
- Resumable uploads
- Normalize resumable GET “test chunk” handling in `UploadModel` using `resumableChunkNumber` + `resumableIdentifier`, returning explicit `status: "found"|"not found"`.
- Skip CSRF checks for resumable GET tests in `UploadController`, but keep strict CSRF validation for real POST uploads with soft-fail `csrf_expired` responses.
- Refactor `UploadModel::handleUpload()` for chunked uploads: strict filename validation, safe folder normalization, reliable temp chunk directory creation, and robust merge with clear errors if any chunk is missing.
- Add `UploadModel::removeChunks()` + internal `rrmdir()` to safely clean up `resumable_…` temp folders via a dedicated controller endpoint.
- Frontend resumable UX & persistence
- Enable `testChunks: true` for Resumable.js and wire GET checks to the new backend status logic.
- Track in-progress resumable files per user in `localStorage` (identifier, filename, folder, size, lastPercent, updatedAt) and show a resumable hint banner inside the Upload card with a dismiss button that clears the hints for that folder.
- Clamp client-side progress to max `99%` until the server confirms success, so aborted tabs still show resumable state instead of “100% done”.
- Improve progress UI: show upload speed, spinner while finalizing, and ensure progress elements exist even for non-standard flows (e.g., submit without prior list build).
- On complete success, clear the progress UI, reset the file input, cancel Resumables internal queue, clear draft records for the folder, and re-show the resumable banner only when appropriate.
- Hiding resumable temp folders
- Hide `resumable_…` folders alongside `trash` and `profile_pics` in:
- Folder tree BFS traversal (child discovery / recursion).
- `listChildren.php` results and child-cache hydration.
- The inline folder strip above the file list (also filtered in `fileListView.js`).
- Folder manager context menu upgrade
- Replace the old ad-hoc folder context menu with a unified `filr-menu` implementation that mirrors the file context menu styling.
- Add Material icon mapping per action (`create_folder`, `move_folder`, `rename_folder`, `color_folder`, `folder_share`, `delete_folder`) and clamp the menu to viewport with escape/outside-click close behavior.
- Wire the new menu from both tree nodes and breadcrumb links, respecting locked folders and current folder capabilities.
- File context menu & selection logic
- Define a semantic file context menu in `index.html` (`#fileContextMenu` with `.filr-menu` buttons, icons, `data-action`, and `data-when` visibility flags).
- Rebuild `fileMenu.js` to:
- Derive the current selection from file checkboxes and map back to real `fileData` entries, handling the encoded row IDs.
- Toggle menu items based on selection state (`any`, `one`, `many`, `zip`, `can-edit`) and hide redundant separators.
- Position the menu within the viewport, add ESC/outside-click dismissal, and delegate click handling to call the existing file actions (preview, edit, rename, copy/move/delete/download/extract, tag single/multiple).
- Tagging system robustness
- Refactor `fileTags.js` to enforce single-instance modals for both single-file and multi-file tagging, preventing duplicate DOM nodes and double bindings.
- Centralize global tag storage (`window.globalTags` + `localStorage`) with shared dropdowns for both modals, including “×” removal for global tags that syncs back to the server.
- Make the tag modals safer and more idempotent (re-usable DOM, Esc and backdrop-to-close, defensive checks on elements) while keeping the existing file row badge rendering and tag-based filtering behavior.
- Localize various tag-related strings where possible and ensure gallery + table views stay in sync after tag changes.
- Visual polish & theming
- Introduce a shared `--menu-radius` token and apply it across login form, file list container, restore modal, preview modals, OnlyOffice modal, user dropdown menus, and the Upload / Folder Management cards for consistent rounded corners.
- Update header button hover to use the same soft blue hover as other interactive elements and tune card shadows for light vs dark mode.
- Adjust media preview modal background to a darker neutral and tweak `filePreview` panel background fallback (`--panel-bg` / `--bg-color`) for better dark mode contrast.
- Style `.filr-menu` for both file + folder menus with max-height, scrolling, proper separators, and Material icons inheriting text color in light and dark themes.
- Align the user dropdown menu hover/active styles with the new menu hover tokens (`--filr-row-hover-bg`, `--filr-row-outline-hover`) for a consistent interaction feel.
---
## Changes 11/13/2025 (v1.9.5)
release(v1.9.5): harden folder tree DOM, add a11y to “Load more”, and guard folder paths
- Replace innerHTML-based row construction in folderManager.js with safe DOM APIs
(createElement, textContent, dataset). All user-derived strings now use
textContent; only locally-generated SVG remains via innerHTML.
- Add isSafeFolderPath() client-side guard; fail closed on suspicious paths
before rendering clickable nodes.
- “Load more” button rebuilt with proper a11y:
- aria-label, optional aria-controls to the UL
- aria-busy + disabled during fetch; restore state only if the node is still
present (Node.isConnected).
- Keep lazy tree + cursor pagination behavior intact; chevrons/icons continue to
hydrate from server hints (hasSubfolders/nonEmpty) once available.
- Addresses CodeQL XSS findings by removing unsafe HTML interpolation and
avoiding HTML interpretation of extracted text.
No breaking changes; security + UX polish on top of v1.9.4.
---
## Changes 11/13/2025 (v1.9.4)
release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more” (closes #66)
**Big focus on folder management performance & UX for large libraries.**
feat(folder-tree):
- Lazy-load children on demand with cursor-based pagination (`nextCursor` + `limit`), including inline “Load more” row.
- BFS-based initial selection: if user cant view requested/default folder, auto-pick the first accessible folder (but stick to (Root) when user can view it).
- Persisted expansion state across reloads; restore saved path and last opened folder; prevent navigation into locked folders (shows i18n toast instead).
- Breadcrumb now respects ACL: clicking a locked crumb toggles expansion only (no navigation).
- Live chevrons from server truth: `hasSubfolders` is computed server-side to avoid file count probes and show correct expanders (even when a direct child is unreadable).
- Capabilities-driven toolbar enable/disable for create/move/rename/color/delete/share.
- Color-carry on move/rename + expansion state migration so moved/renamed nodes keep colors and stay visible.
- Root DnD honored only when viewable; structural locks disable dragging.
perf(core):
- New `FS.php` helpers: safe path resolution (`safeReal`), segment sanitization, symlink defense, ignore/skip lists, bounded child counting, `hasSubfolders`, and `hasReadableDescendant` (depth-limited).
- Thin caching for child lists and counts, with targeted cache invalidation on move/rename/create/delete.
- Bounded concurrency for folder count requests; short timeouts to keep UI snappy.
api/model:
- `FolderModel::listChildren(...)` now returns items shaped like:
`{ name, locked, hasSubfolders, nonEmpty? }`
- `nonEmpty` included only for unlocked nodes (prevents side-channel leakage).
- Locked nodes are only returned when `hasReadableDescendant(...)` is true (preserves legacy “structural visibility without listing the entire tree” behavior).
- `public/api/folder/listChildren.php` delegates to controller/model; `isEmpty.php` hardened; `capabilities.php` exposes `canView` (or derived) for fast checks.
- Folder color endpoints gate results by ACL so users only see colors for folders they can at least “own-view”.
ui/ux:
- New “Load more” row (`<li class="load-more">`) with dark-mode friendly ghost button styling; consistent padding, focus ring, hover state.
- Locked folders render with padlock overlay and no DnD; improved contrast/spacing; icons/chevrons update live as children load.
- i18n additions: `no_access`, `load_more`, `color_folder(_saved|_cleared)`, `please_select_valid_folder`, etc.
- When a user has zero access anywhere, tree selects (Root) but shows `no_access` instead of “No files found”.
security:
- Stronger path traversal + symlink protections across folder APIs (all joins normalized, base-anchored).
- Reduced metadata leakage by omitting `nonEmpty` for locked nodes and depth-limiting descendant checks.
fixes:
- Chevron visibility for unreadable intermediate nodes (e.g., “Files” shows a chevron when it contains a readable “Resources” descendant).
- Refresh now honors the actively viewed folder (session/localStorage), not the first globally readable folder.
chore:
- CSS additions for locked state, tree rows, and dark-mode ghost buttons.
- Minor code cleanups and comments across controller/model and JS tree logic.
---
## Changes 11/11/2025 (v1.9.3)
release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release
- UI / Icons
- Replace Material icon in folder strip with shared `folderSVG()` and export it for reuse. Adds clipPaths, subtle gradients, and `shape-rendering: geometricPrecision` to eliminate the tiny seam.
- Add ruled “paper” lines and blue handwriting dashes; CSS for `.paper-line` and `.paper-ink` included.
- Match strokes between tree (24px) and strip (48px) so both look identical; round joins/caps to avoid nicks.
- Polish folder strip layout & hover: tighter spacing, centered icon+label, improved wrapping.
- Folder color & non-empty detection
- Live color sync: after saving a color we dispatch `folderColorChanged`; strip repaints and tree refreshes.
- Async strip icon: paint immediately, then flip to “paper” if the folder has contents. HSL helpers compute front/back/stroke shades.
- FileList strip
- Render subfolders with `<span class="folder-svg">` + name, wire context menu actions (move, color, share, etc.), and attach icons for each tile.
- Exports & helpers
- Export `openColorFolderModal(...)` and `openMoveFolderUI(...)` for the strip and toolbar; use `refreshFolderIcon(...)` after ops to keep icons current.
- AppCore
- Update file upload DnD relay hook to `#fileList` (id rename).
- CSS tweaks
- Bring tree icon stroke/paint rules in line with the strip, add scribble styles, and adjust margins/spacing.
- CI/CD (release)
- Build PHP dependencies during release: setup PHP 8.3 + Composer, cache downloads, install into `staging/vendor/`, exclude `vendor/` from placeholder checks, and ship artifact including `vendor/`.
- Changelog highlights
- Sharper, seam-free folder SVGs shared across tree & strip, with paper lines + handwriting accents.
- Real-time folder color propagation between views.
- Folder strip switched to SVG tiles with better layout + context actions.
- Release pipeline now produces a ready-to-run zip that includes `vendor/`.
---
## Changes 11/10/2025 (v1.9.2)
release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback)

509
README.md
View File

@@ -10,460 +10,181 @@
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-❤-red)](https://github.com/sponsors/error311)
[![Support on Ko-fi](https://img.shields.io/badge/Ko--fi-Buy%20me%20a%20coffee-orange)](https://ko-fi.com/error311)
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [ONLYOFFICE](#quick-start-onlyoffice-optional) • [FAQ](#faq--troubleshooting)
**FileRise** is a modern, selfhosted web file manager / WebDAV server.
Drag & drop uploads, ACLaware sharing, OnlyOffice integration, and a clean UI — all in a single PHP app that you control.
**Elevate your File Management** A modern, self-hosted web file manager.
Upload, organize, and share files or folders through a sleek, responsive web interface.
**FileRise** is lightweight yet powerful — your personal cloud drive that you fully control.
- 💾 **Selfhosted “cloud drive”** Runs anywhere with PHP (or via Docker). No external DB required.
- 🔐 **Granular perfolder ACLs** View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
- 🔄 **Fast draganddrop uploads** Chunked, resumable uploads with pause/resume and progress.
- 🌳 **Scales to huge trees** Tested with **100k+ folders** in the sidebar tree.
- 🧩 **ONLYOFFICE support (optional)** Edit DOCX/XLSX/PPTX using your own Document Server.
- 🌍 **WebDAV** Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP.
- 🎨 **Polished UI** Dark/light mode, responsive layout, inbrowser previews & code editor.
- 🔑 **Login + SSO** Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
Now featuring **Granular Access Control (ACL)** with per-folder permissions, inheritance, and live admin editing.
Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage* on a per-user, per-folder basis — enforced across the UI, API, and WebDAV.
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v1.9.7.png)
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
**10/25/2025 Video demo:**
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
![filerise-v1 9 0](https://github.com/user-attachments/assets/a346dd8a-eef1-4180-8140-4c1c08e6026e)
> 💡 Looking for **FileRise Pro** (brandable header, Pro features, license handling)?
> Check out [filerise.net](https://filerise.net) FileRise Core stays fully opensource (MIT).
---
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
## Quick links
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with resumable chunked uploads, pause/resume, and real-time progress. If your connection drops, FileRise resumes automatically.
- 🗂️ **File Management:** Full suite of operations — move/copy (via drag-drop or dialogs), rename, and batch delete. Download selected files as ZIPs or extract uploaded ZIPs server-side. Organize with an interactive folder tree and breadcrumbs for instant navigation.
- 🗃️ **Folder & File Sharing:** Share folders or individual files with expiring, optionally password-protected links. Shared folders can accept external uploads (if enabled). Listings are paginated (10 items/page) with file sizes shown in MB.
- 🔐 **Granular Access Control (ACL):**
Per-folder permissions for **owners**, **view**, **view (own)**, **write**, **manage**, **share**, and extended granular capabilities.
Each grant controls specific actions across the UI, API, and WebDAV:
| Permission | Description |
|-------------|-------------|
| **Manage (Owner)** | Full control of folder and subfolders. Can edit ACLs, rename/delete/create folders, and share items. Implies all other permissions for that folder and below. |
| **View (All)** | Allows viewing all files within the folder. Required for folder-level sharing. |
| **View (Own)** | Restricts visibility to files uploaded by the user only. Ideal for drop zones or limited-access users. |
| **Write** | Grants general write access — enables renaming, editing, moving, copying, deleting, and extracting files. |
| **Create** | Allows creating subfolders. Automatically granted to *Manage* users. |
| **Upload** | Allows uploading new files without granting full write privileges. |
| **Edit / Rename / Copy / Move / Delete / Extract** | Individually toggleable granular file operations. |
| **Share File / Share Folder** | Controls sharing capabilities. Folder shares require full View (All). |
- **Automatic Propagation:** Enabling **Manage** on a folder applies to all subfolders; deselecting subfolder permissions overrides inheritance in the UI.
ACL enforcement is centralized and atomic across:
- **Admin Panel:** Interactive ACL editor with batch save and dynamic inheritance visualization.
- **API Endpoints:** All file/folder operations validate server-side.
- **WebDAV:** Uses the same ACL engine — View / Own determine listings, granular permissions control upload/edit/delete/create.
- 🔌 **WebDAV (ACL-Aware):** Mount FileRise as a drive (Cyberduck, WinSCP, Finder, etc.) or access via `curl`.
- Listings require **View** or **View (Own)**.
- Uploads require **Upload**.
- Overwrites require **Edit**.
- Deletes require **Delete**.
- Creating folders requires **Create** or **Manage**.
- All ACLs and ownership rules are enforced exactly as in the web UI.
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) with interactive HTML docs (`api.html`) via Redoc.
- 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers.
- 🧩 **Office Docs (ONLYOFFICE, optional):** View/edit DOCX, XLSX, PPTX (and ODT/ODS/ODP, PDF view) using your self-hosted ONLYOFFICE Document Server. Enforced by the same ACLs as the web UI & WebDAV.
- 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents.
- 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak).
- 🗑️ **Trash & Recovery:** Deleted items move to Trash for recovery (default 3-day retention). Admins can restore or purge globally.
- 🎨 **Responsive UI (Dark/Light Mode):** Modern, mobile-friendly design with persistent preferences (theme, layout, last folder, etc.).
- 🌐 **Internationalization:** English, Spanish, French, German & Simplified Chinese available. Community translations welcome.
- ⚙️ **Lightweight & Self-Contained:** Runs on PHP 8.3+, no external DB required. Single-folder or Docker deployment with minimal footprint, optimized for Unraid and self-hosting.
(For full features and changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [CHANGELOG](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or [Releases](https://github.com/error311/FileRise/releases).)
- 🚀 **Live demo:** [Demo](https://demo.filerise.net) (username: `demo` / password: `demo`)
- 📚 **Docs & Wiki:** [Wiki](https://github.com/error311/FileRise/wiki)
- [Features overview](https://github.com/error311/FileRise/wiki/Features)
- [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)
- 📝 **Changelog:** [Changes](https://github.com/error311/FileRise/blob/master/CHANGELOG.md)
---
## Live Demo
## 1. What FileRise does
[![Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://demo.filerise.net)
**Demo credentials:** `demo` / `demo`
FileRise turns a folder on your server into a **webbased file explorer** with:
Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (login with username “demo” and password “demo”). **The demo is read-only for security.** Explore the interface, switch themes, preview files, and see FileRise in action!
- Folder tree + breadcrumbs for fast navigation
- Multifile/folder draganddrop uploads
- Move / copy / rename / delete / extract ZIP
- Public share links (optionally passwordprotected & expiring)
- Tagging and search by name, tag, uploader, and content
- Trash with restore/purge
- Inline previews (images, audio, video, PDF) and a builtin 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.
---
## Installation & Setup
## 2. Install (Docker recommended)
Deploy FileRise using the **Docker image** (quickest) or a **manual install** on a PHP web server.
---
### Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `TIMEZONE` | `UTC` | PHP/app timezone. |
| `DATE_TIME_FORMAT` | `m/d/y h:iA` | Display format used in UI. |
| `TOTAL_UPLOAD_SIZE` | `5G` | Max combined upload per request (resumable). |
| `SECURE` | `false` | Set `true` if served behind HTTPS proxy (affects link generation). |
| `PERSISTENT_TOKENS_KEY` | *(required)* | Secret for “Remember Me” tokens. Change from the example! |
| `PUID` / `PGID` | `1000` / `1000` | Map `www-data` to host uid:gid (Unraid: often `99:100`). |
| `CHOWN_ON_START` | `true` | First run: try to chown mounted dirs to PUID:PGID. |
| `SCAN_ON_START` | `true` | Reindex files added outside UI at boot. |
| `SHARE_URL` | *(blank)* | Override base URL for share links; blank = auto-detect. |
---
### 1) Running with Docker (Recommended)
#### Pull the image
The easiest way to run FileRise is the official Docker image.
```bash
docker pull error311/filerise-docker:latest
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
```
#### Run a container
Then visit:
```bash
docker run -d \
--name filerise \
-p 8080:80 \
-e TIMEZONE="America/New_York" \
-e DATE_TIME_FORMAT="m/d/y h:iA" \
-e TOTAL_UPLOAD_SIZE="5G" \
-e SECURE="false" \
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
-e PUID="1000" \
-e PGID="1000" \
-e CHOWN_ON_START="true" \
-e SCAN_ON_START="true" \
-e SHARE_URL="" \
-v ~/filerise/uploads:/var/www/uploads \
-v ~/filerise/users:/var/www/users \
-v ~/filerise/metadata:/var/www/metadata \
error311/filerise-docker:latest
```text
http://your-server-ip:8080
```
The app runs as www-data mapped to PUID/PGID. Ensure your mounted uploads/, users/, metadata/ are owned by PUID:PGID (e.g., chown -R 1000:1000 …), or set PUID/PGID to match existing host ownership (e.g., 99:100 on Unraid). On NAS/NFS, apply the ownership change on the host/NAS.
On first launch youll be guided through creating the **initial admin user**.
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
**Notes**
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
- `CHOWN_ON_START=true` is recommended on **first run**. Set to **false** later for faster restarts.
- `SCAN_ON_START=true` indexes files added outside the UI so their metadata appears.
- `SHARE_URL` optional; leave blank to auto-detect host/scheme. Set to site root (e.g., `https://files.example.com`) if needed.
- Set `SECURE="true"` if you serve via HTTPS at your proxy layer.
**Verify ownership mapping (optional)**
```bash
docker exec -it filerise id www-data
# expect: uid=1000 gid=1000 (or 99/100 on Unraid)
```
#### Using Docker Compose
Save as `docker-compose.yml`, then `docker-compose up -d`:
```yaml
services:
filerise:
image: error311/filerise-docker:latest
container_name: filerise
ports:
- "8080:80"
environment:
TIMEZONE: "UTC"
DATE_TIME_FORMAT: "m/d/y h:iA"
TOTAL_UPLOAD_SIZE: "10G"
SECURE: "false"
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
# Ownership & indexing
PUID: "1000" # Unraid users often use 99
PGID: "1000" # Unraid users often use 100
CHOWN_ON_START: "true" # first run; set to "false" afterwards
SCAN_ON_START: "true" # index files added outside the UI at boot
# Sharing URL (optional): leave blank to auto-detect from host/scheme
SHARE_URL: ""
volumes:
- ./uploads:/var/www/uploads
- ./users:/var/www/users
- ./metadata:/var/www/metadata
restart: unless-stopped
```
Access at `http://localhost:8080` (or your servers IP).
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string.
-`CHOWN_ON_START=true` attempts to align ownership **inside the container**; if the host/NAS disallows changes, set the correct UID/GID on the host.”
**First-time Setup**
On first launch, if no users exist, youll be prompted to create an **Admin account**. Then use **User Management** to add more users.
**More Docker options (Unraid, dockercompose, env vars, reverse proxy, etc.)**
See the Docker repo: [docker repo](https://github.com/error311/filerise-docker)
---
### 2) Manual Installation (PHP/Apache)
## 3. Manual install (PHP web server)
If you prefer a traditional web server (LAMP stack or similar):
Prefer baremetal or your own stack? FileRise is just PHP + a few extensions.
**Requirements**
- PHP **8.3+**
- Apache (mod_php) or another web server configured for PHP
- PHP extensions: `json`, `curl`, `zip` (and typical defaults). No database required.
- Web server (Apache / Nginx / Caddy + PHPFPM)
- PHP extensions: `json`, `curl`, `zip` (and usual defaults)
- No database required
**Download Files**
**Steps**
```bash
git clone https://github.com/error311/FileRise.git
```
1. Clone or download FileRise into your web root:
Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine.
**Composer (if applicable)**
```bash
composer install
```
**Folders & Permissions**
```bash
mkdir -p uploads users metadata
chown -R www-data:www-data uploads users metadata # use your web user
chmod -R 775 uploads users metadata
```
- `uploads/`: actual files
- `users/`: credentials & token storage
- `metadata/`: file metadata (tags, share links, etc.)
**Configuration**
Edit `config.php`:
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
- `TOTAL_UPLOAD_SIZE` (ensure PHP `upload_max_filesize` and `post_max_size` meet/exceed this).
- `PERSISTENT_TOKENS_KEY` for “Remember Me” tokens.
**Share link base URL**
- Set **`SHARE_URL`** via web-server env vars (preferred),
**or** keep using `BASE_URL` in `config.php` as a fallback.
- If neither is set, FileRise auto-detects from the current host/scheme.
**Web server config**
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
- Nginx/other: replicate basic protections (no directory listing, deny sensitive files). See Wiki for examples.
Browse to your FileRise URL; youll be prompted to create the Admin user on first load.
---
### 3) Admins
> **Admins in ACL UI**
> Admin accounts appear in the Folder Access and User Permissions modals as **read-only** with full access implied. This is by design—admins always have full control and are excluded from save payloads.
---
## Unraid
- Install from **Community Apps** → search **FileRise**.
- Default **bridge**: access at `http://SERVER_IP:8080/`.
- **Custom br0** (own IP): map host ports to **80/443** if you want bare `http://CONTAINER_IP/` without a port.
- See the [support thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific help.
---
## Upgrade
```bash
docker pull error311/filerise-docker:latest
docker stop filerise && docker rm filerise
# re-run with the same -v and -e flags you used originally
```
---
## Quick-start: Mount via WebDAV
Once FileRise is running, enable WebDAV in the admin panel.
```bash
# Linux (GVFS/GIO)
gio mount dav://demo@your-host/webdav.php/
# macOS (Finder → Go → Connect to Server…)
https://your-host/webdav.php/
```
> Finder typically uses `https://` (or `http://`) URLs for WebDAV, while GNOME/KDE use `dav://` / `davs://`.
### Windows (File Explorer)
- Open **File Explorer** → Right-click **This PC****Map network drive…**
- Choose a drive letter (e.g., `Z:`).
- In **Folder**, enter:
```text
https://your-host/webdav.php/
```
- Check **Connect using different credentials**, then enter your FileRise username/password.
- Click **Finish**.
📖 See the full [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV) for SSL setup, HTTP workaround, and troubleshooting.
---
## Quick start: ONLYOFFICE (optional)
FileRise can open & edit office docs using your **self-hosted ONLYOFFICE Document Server**.
**What you need**
- A reachable ONLYOFFICE Document Server (Community/Enterprise).
- A shared **JWT secret** used by FileRise and your Document Server.
**Setup (23 minutes)**
1. In FileRise go to **Admin → ONLYOFFICE** and:
- ✅ Enable ONLYOFFICE
- 🔗 Set **Document Server Origin** (e.g., `https://docs.example.com`)
- 🔑 Enter **JWT Secret** (click “Replace” to set)
2. (Recommended) Click **Run tests** in the ONLYOFFICE card:
- Checks FileRise status, callback reachability, `api.js` load, and iframe embed.
3. Update your **Content-Security-Policy** to allow the DS origin.
The Admin panel shows a ready-to-copy line for Apache & Nginx. Example:
**Apache**
```apache
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com"
```bash
git clone https://github.com/error311/FileRise.git
```
**Nginx**
2. Create data directories and set permissions:
```nginx
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com" always;
```bash
cd FileRise
mkdir -p uploads users metadata
chown -R www-data:www-data uploads users metadata # adjust for your web user
chmod -R 775 uploads users metadata
```
**Notes**
- If your site is https://, your Document Server must also be https:// (or the browser will block it as mixed content).
- Editor access respects FileRise ACLs (view/edit/share) exactly like the rest of the app.
3. (Optional) Install PHP dependencies with Composer:
```bash
composer install
```
4. Configure PHP (upload limits / timeouts) and ensure rewrites are enabled.
- Apache: allow `.htaccess` or copy its rules into your vhost.
- Nginx/Caddy: mirror the basic protections (no directory listing, block sensitive files).
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.
---
## FAQ / Troubleshooting
## 4. WebDAV & ONLYOFFICE (optional)
- **ONLYOFFICE editor wont load / blank frame:** Verify CSP allows your DS origin (`script-src`, `frame-src`, `connect-src`) and that the DS is reachable over HTTPS if your site is HTTPS.
- **“Disabled — check JWT Secret / Origin” in tests:** In **Admin → ONLYOFFICE**, set the Document Server Origin and click “Replace” to save a JWT secret. Then re-run tests.
### WebDAV
- **“Upload failed” or large files not uploading:** Ensure `TOTAL_UPLOAD_SIZE` in config and PHPs `post_max_size` / `upload_max_filesize` are set high enough. For extremely large files, you might need to increase `max_execution_time` or rely on resumable uploads in smaller chunks.
Once enabled in the Admin panel, FileRise exposes a WebDAV endpoint (e.g. `/webdav.php`). Use it with:
- **How to enable HTTPS?** FileRise doesnt terminate TLS itself. Run it behind a reverse proxy (Nginx, Caddy, Apache with SSL) or use a companion like nginx-proxy or Caddy in Docker. Set `SECURE="true"` in Docker so FileRise generates HTTPS links.
- **macOS Finder** Go → Connect to Server → `https://your-host/webdav.php/`
- **Windows File Explorer** Map Network Drive → `https://your-host/webdav.php/`
- **Linux (GVFS/Nautilus)** `dav://your-host/webdav.php/`
- Clients like **Cyberduck**, **WinSCP**, etc.
- **Changing Admin or resetting password:** Admin can change any users password via **User Management**. If you lose admin access, edit the `users/users.txt` file on the server passwords are hashed (bcrypt), but you can delete the admin line and restart the app to trigger the setup flow again.
WebDAV operations honor the same ACLs as the web UI.
- **Where are my files stored?** In the `uploads/` directory (or the path you set). Deleted files move to `uploads/trash/`. Tag information is in `metadata/file_metadata.json` and trash metadata in `metadata/trash.json`, etc. Backups are recommended.
- **Updating FileRise:** For Docker, pull the new image and recreate the container. For manual installs, download the latest release and replace files (keep your `config.php` and `uploads/users/metadata`). Clear your browser cache if UI assets changed.
For more Q&A or to ask for help, open a Discussion or Issue.
---
## Security posture
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If youre on ≤1.4.x, please upgrade.
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening).
Advisories: [GHSA-6p87-q9rh-95wh](https://github.com/error311/FileRise/security/advisories/GHSA-6p87-q9rh-95wh) (≤ 1.3.15), [GHSA-jm96-2w52-5qjj](https://github.com/error311/FileRise/security/advisories/GHSA-jm96-2w52-5qjj) (v1.4.0). Fixed in **v1.5.0**. Thanks to [@kiwi865](https://github.com/kiwi865) for reporting.
If youre running ≤1.4.x, please upgrade.
See also: [SECURITY.md](./SECURITY.md) for how to report vulnerabilities.
---
## Contributing
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
Areas to help: translations, bug fixes, UI polish, integrations.
If you like FileRise, a ⭐ star on GitHub is much appreciated!
---
## 💖 Sponsor FileRise
If FileRise saves you time (or sparks joy 😄), please consider supporting ongoing development:
- ❤️ [**GitHub Sponsors:**](https://github.com/sponsors/error311) recurring or one-time - helps fund new features and docs.
- ☕ [**Ko-fi:**](https://ko-fi.com/error311) buy me a coffee.
Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
---
## Community and Support
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) (Announcement and user feedback thread).
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific support or issues.
- **GitHub Discussions:** Use Q&A for setup questions, Ideas for enhancements.
[![Star History Chart](https://api.star-history.com/svg?repos=error311/FileRise&type=Date)](https://star-history.com/#error311/FileRise&Date)
---
## Dependencies
See: [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
### ONLYOFFICE integration
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**.
- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use.
Project page & license: <https://github.com/ONLYOFFICE/DocumentServer> (AGPL-3.0)
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
If you run an ONLYOFFICE Document Server you can open/edit Office documents directly from FileRise (DOCX, XLSX, PPTX, ODT, ODS, ODP; PDFs viewonly).
### PHP Libraries
Configure it in **Admin → ONLYOFFICE**:
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
- **[phpseclib/phpseclib](https://github.com/phpseclib/phpseclib)** (v~3.0.7)
- **[robthree/twofactorauth](https://github.com/RobThree/TwoFactorAuth)** (v^3.0)
- **[endroid/qr-code](https://github.com/endroid/qr-code)** (v^5.0)
- **[sabre/dav](https://github.com/sabre-io/dav)** (^4.4)
- Enable ONLYOFFICE
- Set your Document Server origin (e.g. `https://docs.example.com`)
- Configure a shared JWT secret
- Copy the suggested ContentSecurityPolicy header into your reverse proxy
### Client-Side Libraries
- **Google Fonts** [Roboto](https://fonts.google.com/specimen/Roboto) and **Material Icons** ([Google Material Icons](https://fonts.google.com/icons))
- **[Bootstrap](https://getbootstrap.com/)** (v4.5.2)
- **[CodeMirror](https://codemirror.net/)** (v5.65.5) For code editing functionality.
- **[Resumable.js](https://github.com/23/resumable.js/)** (v1.1.0) For file uploads.
- **[DOMPurify](https://github.com/cure53/DOMPurify)** (v2.4.0) For sanitizing HTML.
- **[Fuse.js](https://fusejs.io/)** (v6.6.2) For indexed, fuzzy searching.
Docs: [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
---
## Acknowledgments
## 5. Security & updates
- [uploader](https://github.com/sensboston/uploader) by @sensboston.
- FileRise is actively maintained and has published security advisories.
- See **SECURITY.md** and GitHub Security Advisories for details.
- To upgrade:
- **Docker:** `docker pull error311/filerise-docker:latest` and recreate the container with the same volumes.
- **Manual:** replace app files with the latest release (keep `uploads/`, `users/`, `metadata/`, and your config).
Please report vulnerabilities responsibly via the channels listed in **SECURITY.md**.
---
## License & Credits
## 6. Community, support & contributing
MIT License see [LICENSE](LICENSE).
This project bundles third-party assets such as Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, and Google Fonts (Roboto, Material Icons).
All third-party code and fonts remain under their original open-source licenses (MIT or Apache 2.0).
- 🧵 **GitHub Discussions & Issues:** ask questions, report bugs, suggest features.
- 💬 **Unraid forum thread:** for Unraidspecific setup and tuning.
- 🌍 **Reddit / selfhosting communities:** occasional release posts & feedback threads.
See THIRD_PARTY.md and the /licenses directory for full license texts and attributions.
Contributions are welcome — from bug fixes and docs to translations and UI polish.
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)
- ☕ [Kofi](https://ko-fi.com/error311)
---
## 7. License & thirdparty code
FileRise Core is released under the **MIT License** see [LICENSE](LICENSE).
It bundles a small set of wellknown client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
All thirdparty code remains under its original licenses.
See `THIRD_PARTY.md` and the `licenses/` folder for full details.

View File

@@ -238,4 +238,59 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
}
// Final: env var wins, else fallback
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
// ------------------------------------------------------------
// FileRise Pro bootstrap wiring
// ------------------------------------------------------------
// Inline license (optional; usually set via Admin UI and PRO_LICENSE_FILE)
if (!defined('FR_PRO_LICENSE')) {
$envLicense = getenv('FR_PRO_LICENSE');
define('FR_PRO_LICENSE', $envLicense !== false ? trim((string)$envLicense) : '');
}
// JSON license file used by AdminController::setLicense()
if (!defined('PRO_LICENSE_FILE')) {
define('PRO_LICENSE_FILE', PROJECT_ROOT . '/users/proLicense.json');
}
// 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';
}
define('FR_PRO_LICENSE_FILE', $lf);
}
// Where Pro code lives by default → inside users volume
$proDir = getenv('FR_PRO_BUNDLE_DIR');
if ($proDir === false || $proDir === '') {
$proDir = PROJECT_ROOT . '/users/pro';
}
$proDir = rtrim($proDir, "/\\");
if (!defined('FR_PRO_BUNDLE_DIR')) {
define('FR_PRO_BUNDLE_DIR', $proDir);
}
// Try to load Pro bootstrap if enabled + present
$proBootstrap = FR_PRO_BUNDLE_DIR . '/bootstrap_pro.php';
if (@is_file($proBootstrap)) {
require_once $proBootstrap;
}
// If bootstrap didnt define these, give safe defaults
if (!defined('FR_PRO_ACTIVE')) {
define('FR_PRO_ACTIVE', false);
}
if (!defined('FR_PRO_INFO')) {
define('FR_PRO_INFO', [
'valid' => false,
'error' => null,
'payload' => null,
]);
}
if (!defined('FR_PRO_BUNDLE_VERSION')) {
define('FR_PRO_BUNDLE_VERSION', null);
}

View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
$controller = new AdminController();
$controller->installProBundle();

View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
$ctrl = new AdminController();
$ctrl->setLicense();

View File

@@ -1,245 +1,18 @@
<?php
// public/api/folder/capabilities.php
/**
* @OA\Get(
* path="/api/folder/capabilities.php",
* summary="Get effective capabilities for the current user in a folder",
* description="Computes the caller's capabilities for a given folder by combining account flags (readOnly/disableUpload), ACL grants (read/write/share), and the user-folder-only scope. Returns booleans indicating what the user can do.",
* operationId="getFolderCapabilities",
* tags={"Folders"},
* security={{"cookieAuth": {}}},
*
* @OA\Parameter(
* name="folder",
* in="query",
* required=false,
* description="Target folder path. Defaults to 'root'. Supports nested paths like 'team/reports'.",
* @OA\Schema(type="string"),
* example="projects/acme"
* ),
*
* @OA\Response(
* response=200,
* description="Capabilities computed successfully.",
* @OA\JsonContent(
* type="object",
* required={"user","folder","isAdmin","flags","canView","canUpload","canCreate","canRename","canDelete","canMoveIn","canShare"},
* @OA\Property(property="user", type="string", example="alice"),
* @OA\Property(property="folder", type="string", example="projects/acme"),
* @OA\Property(property="isAdmin", type="boolean", example=false),
* @OA\Property(
* property="flags",
* type="object",
* required={"folderOnly","readOnly","disableUpload"},
* @OA\Property(property="folderOnly", type="boolean", example=false),
* @OA\Property(property="readOnly", type="boolean", example=false),
* @OA\Property(property="disableUpload", type="boolean", example=false)
* ),
* @OA\Property(property="owner", type="string", nullable=true, example="alice"),
* @OA\Property(property="canView", type="boolean", example=true, description="User can view items in this folder."),
* @OA\Property(property="canUpload", type="boolean", example=true, description="User can upload/edit/rename/move/delete items (i.e., WRITE)."),
* @OA\Property(property="canCreate", type="boolean", example=true, description="User can create subfolders here."),
* @OA\Property(property="canRename", type="boolean", example=true, description="User can rename items here."),
* @OA\Property(property="canDelete", type="boolean", example=true, description="User can delete items here."),
* @OA\Property(property="canMoveIn", type="boolean", example=true, description="User can move items into this folder."),
* @OA\Property(property="canShare", type="boolean", example=false, description="User can create share links for this folder.")
* )
* ),
* @OA\Response(response=400, description="Invalid folder name."),
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
* )
*/
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
header('Content-Type: application/json');
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
$username = (string)($_SESSION['username'] ?? '');
if ($username === '') { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
@session_write_close();
// --- auth ---
$username = $_SESSION['username'] ?? '';
if ($username === '') {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
// --- helpers ---
function loadPermsFor(string $u): array {
try {
if (function_exists('loadUserPermissions')) {
$p = loadUserPermissions($u);
return is_array($p) ? $p : [];
}
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
$all = userModel::getUserPermissions();
if (is_array($all)) {
if (isset($all[$u])) return (array)$all[$u];
$lk = strtolower($u);
if (isset($all[$lk])) return (array)$all[$lk];
}
}
} catch (Throwable $e) {}
return [];
}
function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
$f = ACL::normalizeFolder($folder);
// direct owner
if (ACL::isOwner($user, $perms, $f)) return true;
// ancestor owner
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
$pos = strrpos($f, '/');
if ($pos === false) break;
$f = substr($f, 0, $pos);
if ($f === '' || strcasecmp($f, 'root') === 0) break;
if (ACL::isOwner($user, $perms, $f)) return true;
}
return false;
}
/**
* folder-only scope:
* - Admins: always in scope
* - Non folder-only accounts: always in scope
* - Folder-only accounts: in scope iff:
* - folder == username OR subpath of username, OR
* - user is owner of this folder (or any ancestor)
*/
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
if ($isAdmin) return true;
//$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
//if (!$folderOnly) return true;
$f = ACL::normalizeFolder($folder);
if ($f === 'root' || $f === '') {
// folder-only users cannot act on root unless they own a subfolder (handled below)
return isOwnerOrAncestorOwner($u, $perms, $f);
}
if ($f === $u || str_starts_with($f, $u . '/')) return true;
// Treat ownership as in-scope
return isOwnerOrAncestorOwner($u, $perms, $f);
}
// --- inputs ---
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
// validate folder path
if ($folder !== 'root') {
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
if (empty($parts)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
exit;
}
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
exit;
}
}
$folder = implode('/', $parts);
}
// --- user + flags ---
$perms = loadPermsFor($username);
$isAdmin = ACL::isAdmin($perms);
$readOnly = !empty($perms['readOnly']);
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
// --- ACL base abilities ---
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder);
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder);
// granular base
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
// --- Apply scope + flags to effective UI actions ---
$canView = $canViewBase && $inScope; // keep scope for folder-only
$canUpload = $gUploadBase && !$readOnly && $inScope;
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
$canDelete = $gDeleteBase && !$readOnly && $inScope;
// Destination can receive items if user can create/write (or manage) here
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
$canMoveIn = $canReceive;
$canMoveAlias = $canMoveIn;
$canEdit = $gEditBase && !$readOnly && $inScope;
$canCopy = $gCopyBase && !$readOnly && $inScope;
$canExtract = $gExtractBase && !$readOnly && $inScope;
// Sharing respects scope; optionally also gate on readOnly
$canShare = $canShareBase && $inScope; // legacy umbrella
$canShareFileEff = $gShareFile && $inScope;
$canShareFoldEff = $gShareFolder && $inScope;
// never allow destructive ops on root
$isRoot = ($folder === 'root');
if ($isRoot) {
$canRename = false;
$canDelete = false;
$canShareFoldEff = false;
$canMoveFolder = false;
}
if (!$isRoot) {
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
&& !$readOnly;
}
$owner = null;
try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {}
echo json_encode([
'user' => $username,
'folder' => $folder,
'isAdmin' => $isAdmin,
'flags' => [
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
'readOnly' => $readOnly,
],
'owner' => $owner,
// viewing
'canView' => $canView,
'canViewOwn' => $canViewOwn,
// write-ish
'canUpload' => $canUpload,
'canCreate' => $canCreate,
'canRename' => $canRename,
'canDelete' => $canDelete,
'canMoveIn' => $canMoveIn,
'canMove' => $canMoveAlias,
'canMoveFolder'=> $canMoveFolder,
'canEdit' => $canEdit,
'canCopy' => $canCopy,
'canExtract' => $canExtract,
// sharing
'canShare' => $canShare, // legacy
'canShareFile' => $canShareFileEff,
'canShareFolder' => $canShareFoldEff,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
echo json_encode(FolderController::capabilities($folder, $username), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

@@ -1,30 +1,28 @@
<?php
// public/api/folder/isEmpty.php
// Fast ACL-aware peek for tree icons/chevrons
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
// Snapshot then release session lock so parallel requests dont block
$user = (string)($_SESSION['username'] ?? '');
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
$username = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
'folderOnly' => $_SESSION['folderOnly'] ?? null,
'readOnly' => $_SESSION['readOnly'] ?? null,
];
@session_write_close();
// Input
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/');
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
// Delegate to controller (model handles ACL + path safety)
$result = FolderController::stats($folder, $user, $perms);
// Always return a compact JSON object like before
echo json_encode([
'folders' => (int)($result['folders'] ?? 0),
'files' => (int)($result['files'] ?? 0),
]);
echo json_encode(FolderController::stats($folder, $username, $perms), JSON_UNESCAPED_SLASHES);

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
$username = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
'folderOnly' => $_SESSION['folderOnly'] ?? null,
'readOnly' => $_SESSION['readOnly'] ?? null,
];
@session_write_close();
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
$limit = max(1, min(2000, (int)($_GET['limit'] ?? 500)));
$cursor = isset($_GET['cursor']) && $_GET['cursor'] !== '' ? (string)$_GET['cursor'] : null;
$res = FolderController::listChildren($folder, $username, $perms, $cursor, $limit);
echo json_encode($res, JSON_UNESCAPED_SLASHES);

File diff suppressed because it is too large Load Diff

View File

@@ -277,14 +277,24 @@
</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);">
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;">
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li>
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li>
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a
subfolder.</li>
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click
the appropriate button.</li>
style="display:none;position:absolute;top:50px;right:15px;background:#fff;border:1px solid #ccc;padding:10px;z-index:1000;box-shadow:2px 2px 6px rgba(0,0,0,0.2);border-radius:8px;max-width:320px;line-height:1.35;">
<style>
/* Dark mode polish */
body.dark-mode #folderHelpTooltip {
background:#2c2c2c; border-color:#555; color:#e8e8e8; box-shadow:2px 2px 10px rgba(0,0,0,.5);
}
#folderHelpTooltip .folder-help-list { margin:0; padding-left:18px; }
#folderHelpTooltip .folder-help-list li { margin:6px 0; }
</style>
<ul class="folder-help-list">
<li data-i18n-key="folder_help_click_view">Click a folder in the tree to view its files.</li>
<li data-i18n-key="folder_help_expand_chevrons">Use chevrons to expand/collapse. Locked folders (padlock) can expand but cant be opened.</li>
<li data-i18n-key="folder_help_context_menu">Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.</li>
<li data-i18n-key="folder_help_drag_drop">Drag a folder onto another folder <em>or</em> a breadcrumb to move it.</li>
<li data-i18n-key="folder_help_load_more">For long lists, click “Load more” to fetch the next page of folders.</li>
<li data-i18n-key="folder_help_last_folder">Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.</li>
<li data-i18n-key="folder_help_breadcrumbs">Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.</li>
<li data-i18n-key="folder_help_permissions">Buttons enable/disable based on your permissions for the selected folder.</li>
</ul>
</div>
</div>
@@ -467,6 +477,26 @@
</form>
</div>
</div>
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
<button type="button" class="mi" data-action="create_file" data-when="always"><i class="material-icons">note_add</i><span>Create file</span></button>
<div class="sep" data-when="always"></div>
<button type="button" class="mi" data-action="delete_selected" data-when="any"><i class="material-icons">delete</i><span>Delete selected</span></button>
<button type="button" class="mi" data-action="copy_selected" data-when="any"><i class="material-icons">content_copy</i><span>Copy selected</span></button>
<button type="button" class="mi" data-action="move_selected" data-when="any"><i class="material-icons">drive_file_move</i><span>Move selected</span></button>
<button type="button" class="mi" data-action="download_zip" data-when="any"><i class="material-icons">archive</i><span>Download as ZIP</span></button>
<button type="button" class="mi" data-action="extract_zip" data-when="zip"><i class="material-icons">unarchive</i><span>Extract ZIP</span></button>
<div class="sep" data-when="any"></div>
<button type="button" class="mi" data-action="tag_selected" data-when="many"><i class="material-icons">sell</i><span>Tag selected</span></button>
<button type="button" class="mi" data-action="preview" data-when="one"><i class="material-icons">visibility</i><span>Preview</span></button>
<button type="button" class="mi" data-action="edit" data-when="can-edit"><i class="material-icons">edit</i><span>Edit</span></button>
<button type="button" class="mi" data-action="rename" data-when="one"><i class="material-icons">drive_file_rename_outline</i><span>Rename</span></button>
<button type="button" class="mi" data-action="tag_file" data-when="one"><i class="material-icons">sell</i><span>Tag file</span></button>
</div>
<div id="removeUserModal" class="modal" style="display:none;">
<div class="modal-content">
<h3 data-i18n-key="remove_user_title">Remove User</h3>

View File

@@ -4,8 +4,80 @@ import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
function normalizeLogoPath(raw) {
if (!raw) return '';
const parts = String(raw).split(':');
let pic = parts[parts.length - 1];
pic = pic.replace(/^:+/, '');
if (pic && !pic.startsWith('/')) pic = '/' + pic;
return pic;
}
const version = window.APP_VERSION || "dev";
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
// Hard-coded *FOR NOW* latest FileRise Pro bundle version for UI hints only.
// Update this when I cut a new Pro ZIP.
const PRO_LATEST_BUNDLE_VERSION = 'v1.0.0';
function getAdminTitle(isPro, proVersion) {
const corePill = `
<span class="badge badge-pill badge-secondary admin-core-badge">
Core ${version}
</span>
`;
// Normalize versions so "v1.0.1" and "1.0.1" compare cleanly
const norm = (v) => String(v || '').trim().replace(/^v/i, '');
const latestRaw = (typeof PRO_LATEST_BUNDLE_VERSION !== 'undefined'
? PRO_LATEST_BUNDLE_VERSION
: ''
);
const currentRaw = (proVersion && proVersion !== 'not installed')
? String(proVersion)
: '';
const hasCurrent = !!norm(currentRaw);
const hasLatest = !!norm(latestRaw);
const hasUpdate = isPro && hasCurrent && hasLatest &&
norm(currentRaw) !== norm(latestRaw);
if (!isPro) {
// Free/core only
return `
${t("admin_panel")}
${corePill}
`;
}
const pvLabel = hasCurrent ? `Pro v${norm(currentRaw)}` : 'Pro';
const proPill = `
<span class="badge badge-pill badge-warning admin-pro-badge">
${pvLabel}
</span>
`;
const updateHint = hasUpdate
? `
<a
href="https://filerise.net/pro/update.php"
target="_blank"
rel="noopener noreferrer"
class="badge badge-pill badge-warning admin-pro-badge"
style="cursor:pointer; text-decoration:none; margin-left:4px;">
Pro update available
</a>
`
: '';
return `
${t("admin_panel")}
${corePill}
${proPill}
${updateHint}
`;
}
function buildFullGrantsForAllFolders(folders) {
@@ -16,6 +88,57 @@ function buildFullGrantsForAllFolders(folders) {
};
return folders.reduce((acc, f) => { acc[f] = { ...allTrue }; return acc; }, {});
}
function applyHeaderColorsFromAdmin() {
try {
const lightInput = document.getElementById('brandingHeaderBgLight');
const darkInput = document.getElementById('brandingHeaderBgDark');
const root = document.documentElement;
const light = lightInput ? lightInput.value.trim() : '';
const dark = darkInput ? darkInput.value.trim() : '';
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) {
console.warn('Failed to live-update header colors from admin panel', e);
}
}
function updateHeaderLogoFromAdmin() {
try {
const input = document.getElementById('brandingCustomLogoUrl');
const logoImg = document.querySelector('.header-logo img');
if (!logoImg) return;
let url = (input && input.value.trim()) || '';
// If they used a bare "uploads/..." path, normalize to "/uploads/..."
if (url && !url.startsWith('/') && url.startsWith('uploads/')) {
url = '/' + url;
}
// ---- Sanitize URL (mirror AdminModel::sanitizeLogoUrl) ----
const isHttp = /^https?:\/\//i.test(url);
const isSiteRelative = url.startsWith('/') && !url.includes('://');
// Strip any CR/LF just in case
url = url.replace(/[\r\n]+/g, '');
if (url && (isHttp || isSiteRelative)) {
// safe enough for <img src="...">
logoImg.setAttribute('src', url);
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) {
console.warn('Failed to live-update header logo from admin panel', e);
}
}
/* === BEGIN: Folder Access helpers (merged + improved) === */
function qs(scope, sel) { return (scope || document).querySelector(sel); }
@@ -175,7 +298,7 @@ async function safeJson(res) {
.dark-mode .form-control::placeholder { color:#888; }
.section-header {
background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:4px; font-weight:bold;
background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:12px; font-weight:bold;
display:flex; align-items:center; justify-content:space-between; margin-top:16px;
}
.section-header:first-of-type { margin-top:0; }
@@ -301,7 +424,10 @@ function captureInitialAdminConfig() {
disableOIDCLogin: !!document.getElementById("disableOIDCLogin")?.checked,
enableWebDAV: !!document.getElementById("enableWebDAV")?.checked,
sharedMaxUploadSize: (document.getElementById("sharedMaxUploadSize")?.value || "").trim(),
globalOtpauthUrl: (document.getElementById("globalOtpauthUrl")?.value || "").trim()
globalOtpauthUrl: (document.getElementById("globalOtpauthUrl")?.value || "").trim(),
brandingCustomLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
brandingHeaderBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
};
}
function hasUnsavedChanges() {
@@ -319,7 +445,10 @@ function hasUnsavedChanges() {
getChk("disableOIDCLogin") !== o.disableOIDCLogin ||
getChk("enableWebDAV") !== o.enableWebDAV ||
getVal("sharedMaxUploadSize") !== o.sharedMaxUploadSize ||
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl ||
getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") ||
getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") ||
getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "")
);
}
@@ -355,6 +484,81 @@ function toggleSection(id) {
}
}
export function initProBundleInstaller() {
try {
const fileInput = document.getElementById('proBundleFile');
const btn = document.getElementById('btnInstallProBundle');
const statusEl = document.getElementById('proBundleStatus');
if (!fileInput || !btn || !statusEl) return;
// Allow names like: FileRisePro_v1.0.0.zip or FileRisePro-1.0.0.zip
const PRO_ZIP_NAME_RE = /^FileRisePro[_-]v?[0-9]+\.[0-9]+\.[0-9]+\.zip$/i;
btn.addEventListener('click', async () => {
const file = fileInput.files && fileInput.files[0];
if (!file) {
statusEl.textContent = 'Choose a FileRise Pro .zip bundle first.';
statusEl.className = 'small text-danger';
return;
}
const name = file.name || '';
if (!PRO_ZIP_NAME_RE.test(name)) {
statusEl.textContent = 'Bundle must be named like "FileRisePro_v1.0.0.zip".';
statusEl.className = 'small text-danger';
return;
}
const formData = new FormData();
formData.append('bundle', file);
statusEl.textContent = 'Uploading and installing Pro bundle...';
statusEl.className = 'small text-muted';
try {
const resp = await fetch('/api/admin/installProBundle.php', {
method: 'POST',
headers: {
'X-CSRF-Token': window.csrfToken || ''
},
body: formData
});
let data = null;
try {
data = await resp.json();
} catch (_) {
// ignore JSON parse errors; handled below
}
if (!resp.ok || !data || !data.success) {
const msg = data && data.error
? data.error
: `HTTP ${resp.status}`;
statusEl.textContent = 'Install failed: ' + msg;
statusEl.className = 'small text-danger';
return;
}
const versionText = data.proVersion ? ` (version ${data.proVersion})` : '';
statusEl.textContent = 'Pro bundle installed' + versionText + '. Reload the page to apply changes.';
statusEl.className = 'small text-success';
if (typeof loadAdminConfigFunc === 'function') {
loadAdminConfigFunc();
}
} catch (e) {
statusEl.textContent = 'Install failed: ' + (e && e.message ? e.message : String(e));
statusEl.className = 'small text-danger';
}
});
} catch (e) {
console.warn('Failed to init Pro bundle installer', e);
}
}
function loadShareLinksSection() {
const container = document.getElementById("shareLinksContent");
if (!container) return;
@@ -460,12 +664,22 @@ export function openAdminPanel() {
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
const dark = document.body.classList.contains("dark-mode");
const proInfo = config.pro || {};
const isPro = !!proInfo.active;
const proType = proInfo.type || '';
const proEmail = proInfo.email || '';
const proVersion = proInfo.version || 'not installed';
const proLicense = proInfo.license || '';
const brandingCfg = config.branding || {};
const brandingCustomLogoUrl = brandingCfg.customLogoUrl || "";
const brandingHeaderBgLight = brandingCfg.headerBgLight || "";
const brandingHeaderBgDark = brandingCfg.headerBgDark || "";
const bg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
const inner = `
background:${dark ? "#2c2c2c" : "#fff"};
color:${dark ? "#e0e0e0" : "#000"};
padding:20px; max-width:1100px; width:50%;
border-radius:8px; position:relative;
position:relative;
max-height:90vh; overflow:auto;
border:1px solid ${dark ? "#555" : "#ccc"};
`;
@@ -484,7 +698,7 @@ export function openAdminPanel() {
mdl.innerHTML = `
<div class="modal-content" style="${inner}">
<div class="editor-close-btn" id="closeAdminPanel">&times;</div>
<h3>${adminTitle}</h3>
<h3>${getAdminTitle(isPro, proVersion)}</h3>
<form id="adminPanelForm">
${[
{ id: "userManagement", label: t("user_management") },
@@ -495,6 +709,7 @@ export function openAdminPanel() {
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
{ id: "shareLinks", label: t("manage_shared_links") },
{ id: "pro", label: "FileRise Pro" },
{ id: "sponsor", label: (typeof tf === 'function' ? tf("sponsor_donations", "Sponsor / Donations") : "Sponsor / Donations") }
].map(sec => `
<div id="${sec.id}Header" class="section-header collapsed">
@@ -515,18 +730,100 @@ export function openAdminPanel() {
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
["userManagement", "headerSettings", "loginOptions", "webdav", "onlyoffice", "upload", "oidc", "shareLinks", "sponsor"]
["userManagement", "headerSettings", "loginOptions", "webdav", "onlyoffice", "upload", "oidc", "shareLinks", "pro", "sponsor"]
.forEach(id => {
document.getElementById(id + "Header")
.addEventListener("click", () => toggleSection(id));
});
document.getElementById("userManagementContent").innerHTML = `
<button type="button" id="adminOpenAddUser" class="btn btn-success me-2">${t("add_user")}</button>
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger me-2">${t("remove_user")}</button>
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">${tf("folder_access", "Folder Access")}</button>
<button type="button" id="adminOpenUserFlags" class="btn btn-secondary">${tf("user_permissions", "User Permissions")}</button>
`;
document.getElementById("userManagementContent").innerHTML = `
<div class="admin-user-actions">
<!-- Core buttons -->
<button type="button" id="adminOpenAddUser" class="btn btn-success btn-sm">
<i class="material-icons">person_add</i>
<span>${t("add_user")}</span>
</button>
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger btn-sm">
<i class="material-icons">person_remove</i>
<span>${t("remove_user")}</span>
</button>
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary btn-sm">
<i class="material-icons">folder_shared</i>
<span>${tf("folder_access", "Folder Access")}</span>
</button>
<button type="button" id="adminOpenUserFlags" class="btn btn-secondary btn-sm">
<i class="material-icons">tune</i>
<span>${tf("user_permissions", "User Permissions")}</span>
</button>
<!-- Pro-only: User groups -->
${
isPro
? `
<div class="btn-pro-wrapper">
<button
type="button"
id="adminOpenUserGroups"
class="btn btn-sm btn-pro-admin">
<i class="material-icons">groups</i>
<span>User groups</span>
</button>
</div>
`
: `
<div class="btn-pro-wrapper">
<button
type="button"
id="adminOpenUserGroups"
class="btn btn-sm btn-pro-admin">
<i class="material-icons">groups</i>
<span>User groups</span>
</button>
<span class="btn-pro-pill">Pro · Coming soon</span>
</div>
`
}
<!-- Pro roadmap: Client portal -->
${
isPro
? `
<div class="btn-pro-wrapper">
<button
type="button"
id="adminOpenClientPortal"
class="btn btn-sm btn-pro-admin"
title="Client upload portals are part of FileRise Pro.">
<i class="material-icons">cloud_upload</i>
<span>Client upload portal</span>
</button>
</div>
`
: `
<div class="btn-pro-wrapper">
<button
type="button"
id="adminOpenClientPortal"
class="btn btn-sm btn-pro-admin"
disabled
title="Planned FileRise Pro feature: client upload portals">
<i class="material-icons">cloud_upload</i>
<span>Client upload portal</span>
</button>
<span class="btn-pro-pill">Pro · Coming soon</span>
</div>
`
}
</div>
<small class="text-muted d-block" style="margin-top:6px;">
Use the core tools to manage users and per-folder access.
User groups and Client upload portals are planned FileRise Pro features.
</small>
`;
document.getElementById("adminOpenAddUser")
.addEventListener("click", () => {
@@ -541,13 +838,174 @@ export function openAdminPanel() {
document.getElementById("adminOpenUserPermissions")
.addEventListener("click", openUserPermissionsModal);
document.getElementById("headerSettingsContent").innerHTML = `
<div class="form-group">
<label for="headerTitle">${t("header_title_text")}:</label>
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle || ""}" />
</div>
`;
wireHeaderTitleLive();
// Pro-only stubs for future features
const regBtn = document.getElementById("adminOpenUserRegistration");
const groupsBtn = document.getElementById("adminOpenUserGroups");
const clientBtn = document.getElementById("adminOpenClientPortal");
if (regBtn) {
regBtn.addEventListener("click", () => {
if (!isPro) {
showToast("User registration is a FileRise Pro feature. Visit filerise.net to purchase a license.");
window.open("https://filerise.net", "_blank", "noopener");
return;
}
// Placeholder for future Pro UI:
showToast("User registration management is coming soon in FileRise Pro.");
});
}
if (groupsBtn) {
groupsBtn.addEventListener("click", () => {
if (!isPro) {
showToast("User groups are a FileRise Pro feature. Visit filerise.net to purchase a license.");
window.open("https://filerise.net", "_blank", "noopener");
return;
}
// Placeholder for future Pro UI:
showToast("User groups management is coming soon in FileRise Pro.");
});
}
if (clientBtn) {
clientBtn.addEventListener("click", () => {
if (!isPro) {
showToast("Client portal uploads are a FileRise Pro feature. Visit filerise.net to purchase a license.");
window.open("https://filerise.net", "_blank", "noopener");
return;
}
// Placeholder for future Pro UI:
showToast("Client portal uploads are coming soon in FileRise Pro.");
});
}
document.getElementById("headerSettingsContent").innerHTML = `
<div class="form-group">
<label for="headerTitle">${t("header_title_text")}:</label>
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle || ""}" />
</div>
<!-- Pro: Logo -->
<div class="form-group" style="margin-top:16px;">
<label for="brandingCustomLogoUrl">
Header Logo
${!isPro ? '<span class="badge badge-pill badge-warning admin-pro-badge" style="margin-left:6px;">Pro</span>' : ''}
</label>
<small class="text-muted d-block mb-1">
${isPro
? 'Upload a logo image or paste a local path.'
: 'Requires FileRise Pro to enable custom header branding.'}
</small>
<div class="input-group mb-2">
<input
type="text"
id="brandingCustomLogoUrl"
class="form-control"
placeholder="/uploads/profile_pics/logo.png"
value="${isPro ? (brandingCustomLogoUrl.replace(/"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
<div class="input-group">
<input
type="file"
id="brandingLogoFile"
class="form-control"
accept="image/*"
${!isPro ? 'disabled' : ''}
/>
<button
type="button"
class="btn btn-sm btn-secondary"
id="brandingUploadBtn"
${!isPro ? 'disabled' : ''}>
Upload logo
</button>
</div>
</div>
<!-- Pro: Header colors -->
<div class="form-group" style="margin-top:16px;">
<label>
Header Colors
${!isPro ? '<span class="badge badge-pill badge-warning admin-pro-badge" style="margin-left:6px;">Pro</span>' : ''}
</label>
<div class="d-flex align-items-center" style="gap: 12px; flex-wrap: wrap;">
<div>
<label for="brandingHeaderBgLight" class="d-block" style="font-size: 12px; margin-bottom: 4px;">Light mode</label>
<input
type="color"
id="brandingHeaderBgLight"
value="${brandingHeaderBgLight || '#2196F3'}"
${!isPro ? 'disabled' : ''}
/>
</div>
<div>
<label for="brandingHeaderBgDark" class="d-block" style="font-size: 12px; margin-bottom: 4px;">Dark mode</label>
<input
type="color"
id="brandingHeaderBgDark"
value="${brandingHeaderBgDark || '#1f1f1f'}"
${!isPro ? 'disabled' : ''}
/>
</div>
</div>
<small class="text-muted d-block mt-1">
${isPro
? 'If left empty, FileRise uses its default blue and dark header colors.'
: 'Requires FileRise Pro to enable custom color branding.'}
</small>
</div>
`;
wireHeaderTitleLive();
// Upload logo -> reuse profile picture endpoint, then fill the logo path
if (isPro) {
const fileInput = document.getElementById('brandingLogoFile');
const uploadBtn = document.getElementById('brandingUploadBtn');
const urlInput = document.getElementById('brandingCustomLogoUrl');
if (fileInput && uploadBtn && urlInput) {
uploadBtn.addEventListener('click', async () => {
const f = fileInput.files && fileInput.files[0];
if (!f) {
showToast('Please choose an image first.');
return;
}
const fd = new FormData();
fd.append('brand_logo', f); // <- must match PHP field
try {
const res = await fetch('/api/pro/uploadBrandLogo.php', {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': window.csrfToken },
body: fd
});
const text = await res.text();
let js = {};
try { js = JSON.parse(text || '{}'); } catch (e) { js = {}; }
if (!res.ok || !js.url) {
showToast(js.error || 'Error uploading logo');
return;
}
const normalized = normalizeLogoPath(js.url); // your helper
urlInput.value = normalized;
showToast('Logo uploaded. Don\'t forget to Save settings.');
} catch (e) {
console.error(e);
showToast('Error uploading logo');
}
});
}
}
document.getElementById("loginOptionsContent").innerHTML = `
<div class="form-group"><input type="checkbox" id="disableFormLogin" /> <label for="disableFormLogin">${t("disable_login_form")}</label></div>
@@ -912,6 +1370,267 @@ async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
document.getElementById("shareLinksContent").textContent = t("loading") + "…";
document.getElementById("shareLinksContent").textContent = t("loading") + "…";
// --- FileRise Pro / License section ---
const proContent = document.getElementById("proContent");
if (proContent) {
// Normalize versions so "v1.0.1" and "1.0.1" compare cleanly
const norm = (v) => (String(v || '').trim().replace(/^v/i, ''));
const currentVersionRaw = (proVersion && proVersion !== 'not installed') ? String(proVersion) : '';
const latestVersionRaw = PRO_LATEST_BUNDLE_VERSION || '';
const hasCurrent = !!norm(currentVersionRaw);
const hasLatest = !!norm(latestVersionRaw);
const hasUpdate = hasCurrent && hasLatest && norm(currentVersionRaw) !== norm(latestVersionRaw);
const proMetaHtml =
isPro && (proType || proEmail || proVersion)
? `
<div class="pro-license-meta" style="margin-top:8px;font-size:12px;color:#777;">
<div>
${proType ? `License type: ${proType}` : 'License active'}
${proType && proEmail ? ' • ' : ''}
${proEmail ? `Licensed to: ${proEmail}` : ''}
</div>
${hasCurrent ? `
<div>
Installed Pro bundle: v${norm(currentVersionRaw)}
</div>` : ''}
${hasLatest ? `
<div>
Latest Pro bundle (UI hint): ${latestVersionRaw}
</div>` : ''}
</div>
`
: '';
proContent.innerHTML = `
<div class="card pro-card" style="padding:12px; border:1px solid #ddd; border-radius:12px; max-width:620px; margin:8px auto;">
<div>
<!-- Title row with pill aligned to "FileRise Pro" -->
<div class="d-flex align-items-center" style="gap:8px;">
<strong>FileRise Pro</strong>
<span class="badge badge-pill ${isPro ? 'badge-success' : 'badge-secondary'} admin-pro-badge">
${isPro ? 'Active' : 'Free'}
</span>
</div>
<!-- Subtitle + meta under the title -->
<div style="font-size:12px; color:#777; margin-top:2px;">
${isPro
? 'Pro features are currently enabled on this instance.'
: 'You are running the free edition. Enter a license key to activate FileRise Pro.'}
</div>
${proMetaHtml}
</div>
${isPro ? `
<div style="margin-top:8px;">
<a
href="https://filerise.net/pro/update.php"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-pro-admin d-inline-flex align-items-center"
>
<span>Download latest Pro bundle</span>
${hasUpdate ? `
<span class="badge badge-light" style="margin-left:6px;">
Update available
</span>` : ''}
</a>
<small class="text-muted d-block" style="margin-top:4px;">
Opens filerise.net in a new tab where you can enter your Pro license
to download the latest FileRise Pro ZIP.
</small>
</div>
` : `
<div style="margin-top:8px;">
<a
href="https://filerise.net/pro/checkout.php"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-pro-admin"
>
Buy FileRise Pro
</a>
<small class="text-muted d-block" style="margin-top:4px;">
Opens filerise.net in a new tab so you can purchase a FileRise Pro license.
</small>
</div>
`}
<div class="form-group" style="margin-top:10px;">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="proLicenseInput" style="font-size:12px; margin-bottom:0;">License key</label>
${isPro && proLicense ? `
<button type="button"
class="btn btn-link btn-sm p-0"
id="proCopyLicenseBtn">
Copy current license
</button>
` : ''}
</div>
<textarea
id="proLicenseInput"
class="form-control"
rows="3"
placeholder="Paste your FileRise Pro license key here..."></textarea>
<small class="text-muted">
You can purchase a license at
<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">filerise.net</a>.
</small>
</div>
<div class="form-group" style="margin-top:6px;">
<label style="font-size:12px;">Or upload license file</label>
<input
type="file"
id="proLicenseFile"
class="form-control-file"
accept=".lic,.json,.txt,.filerise-lic"
/>
<small class="text-muted">
Supported: FileRise.lic, plain text with FRP1... or JSON containing a <code>license</code> field.
</small>
</div>
<button type="button" class="btn btn-primary btn-sm" id="proSaveLicenseBtn" style="margin-top:8px;">
Save license
</button>
<div class="mt-3 border-top pt-3" style="margin-top:14px;">
<h6 class="mb-1">Install / update Pro bundle</h6>
<p class="text-muted small mb-2">
Upload the <code>.zip</code> bundle you downloaded from <a href="https://filerise.net" target="_blank" rel="noopener noreferrer">filerise.net</a>.
This runs locally on your server and never contacts an external update service.
</p>
<div class="d-flex flex-wrap align-items-center gap-2" style="margin-top:4px;">
<input type="file"
id="proBundleFile"
accept=".zip"
class="form-control-file mb-2 mb-sm-0" />
<button type="button"
id="btnInstallProBundle"
class="btn btn-sm btn-pro-admin">
Install Pro bundle
</button>
</div>
<div id="proBundleStatus" class="small mt-2"></div>
</div>
</div>
`;
// Wire up local Pro bundle installer (upload .zip into core)
initProBundleInstaller();
// Pre-fill textarea with saved license if present
const licenseTextarea = document.getElementById('proLicenseInput');
if (licenseTextarea && proLicense) {
licenseTextarea.value = proLicense;
}
// Auto-load license when a file is selected
const fileInput = document.getElementById('proLicenseFile');
if (fileInput && licenseTextarea) {
fileInput.addEventListener('change', () => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
let raw = String(e.target.result || '').trim();
let license = raw;
try {
const js = JSON.parse(raw);
if (js && typeof js.license === 'string') {
license = js.license.trim();
}
} catch (_) {
// not JSON, treat as plain text
}
if (!license || !license.startsWith('FRP1.')) {
showToast('Could not find a valid FRP1 license in that file.');
return;
}
licenseTextarea.value = license;
showToast('License loaded from file. Click "Save license" to apply.');
};
reader.onerror = () => {
showToast('Error reading license file.');
};
reader.readAsText(file);
});
}
// Copy current license button (now inline next to the label)
const proCopyBtn = document.getElementById('proCopyLicenseBtn');
if (proCopyBtn && proLicense) {
proCopyBtn.addEventListener('click', async () => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(proLicense);
} else {
const ta = document.createElement('textarea');
ta.value = proLicense;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
showToast('License copied to clipboard.');
} catch {
showToast('Could not copy license. Please copy it manually.');
}
});
}
// Save license handler (unchanged)
const proSaveBtn = document.getElementById('proSaveLicenseBtn');
if (proSaveBtn) {
proSaveBtn.addEventListener('click', async () => {
const ta = document.getElementById('proLicenseInput');
const license = (ta && ta.value.trim()) || '';
try {
const res = await fetch('/api/admin/setLicense.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '')
},
body: JSON.stringify({ license }),
});
const text = await res.text();
let data = {};
try { data = JSON.parse(text || '{}'); } catch (e) { data = {}; }
if (!res.ok || !data.success) {
console.error('setLicense error:', res.status, text);
showToast(data.error || 'Error saving license');
return;
}
showToast('License saved. Reloading…');
window.location.reload();
} catch (e) {
console.error(e);
showToast('Error saving license');
}
});
}
}
// --- end FileRise Pro section ---
document.getElementById("saveAdminSettings")
.addEventListener("click", handleSave);
["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"].forEach(id => {
@@ -1065,6 +1784,11 @@ function handleSave() {
// clientId/clientSecret: only include when replacing
},
globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim(),
branding: {
customLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
headerBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
headerBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
},
};
const idEl = document.getElementById("oidcClientId");
@@ -1119,6 +1843,8 @@ function handleSave() {
if (j.error) { showToast('Error: ' + j.error); return; }
showToast('Settings saved.');
closeAdminPanel();
applyHeaderColorsFromAdmin();
updateHeaderLogoFromAdmin();
})
.catch(() => showToast('Save failed.'));
}
@@ -1525,7 +2251,6 @@ export function openUserPermissionsModal() {
padding: 20px;
width: clamp(980px, 92vw, 1280px);
max-width: none;
border-radius: 8px;
position: relative;
max-height: 90vh;
overflow: auto;
@@ -1654,7 +2379,7 @@ export async function openUserFlagsModal() {
<div class="modal-content"
style="background:${contentBg}; color:${contentFg};
padding:16px; max-width:900px; width:95%;
border-radius:8px; position:relative;
position:relative;
border:1px solid ${borderCol};">
<span id="closeUserFlagsModal"
class="editor-close-btn"

View File

@@ -100,7 +100,7 @@ export function initializeApp() {
// Hook DnD relay from fileList area into upload area
const fileListArea = document.getElementById('fileListContainer');
const fileListArea = document.getElementById('fileList');
if (fileListArea) {
let hoverTimer = null;

View File

@@ -195,7 +195,6 @@ export async function openUserPanel() {
color: ${isDark ? '#e0e0e0' : '#000'};
padding: 20px;
max-width: 600px; width:90%;
border-radius: 8px;
overflow-y: auto; max-height: 500px;
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
box-sizing: border-box;

View File

@@ -156,7 +156,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
export function buildFileTableHeader(sortOrder) {
return `
<table class="table">
<table class="table filr-table table-hover table-striped">
<thead>
<tr>
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
@@ -283,9 +283,9 @@ export function updateRowHighlight(checkbox) {
const row = checkbox.closest('tr');
if (!row) return;
if (checkbox.checked) {
row.classList.add('row-selected');
row.classList.add('row-selected', 'selected');
} else {
row.classList.remove('row-selected');
row.classList.remove('row-selected', 'selected');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,154 +1,246 @@
// fileMenu.js
import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}';
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { updateRowHighlight } from './domUtils.js?v={{APP_QVER}}';
import {
handleDeleteSelected, handleCopySelected, handleMoveSelected,
handleDownloadZipSelected, handleExtractZipSelected,
renameFile, openCreateFileModal
} from './fileActions.js?v={{APP_QVER}}';
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
export function showFileContextMenu(x, y, menuItems) {
let menu = document.getElementById("fileContextMenu");
if (!menu) {
menu = document.createElement("div");
menu.id = "fileContextMenu";
menu.style.position = "fixed";
menu.style.backgroundColor = "#fff";
menu.style.border = "1px solid #ccc";
menu.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.2)";
menu.style.zIndex = "9999";
menu.style.padding = "5px 0";
menu.style.minWidth = "150px";
document.body.appendChild(menu);
}
menu.innerHTML = "";
menuItems.forEach(item => {
let menuItem = document.createElement("div");
menuItem.textContent = item.label;
menuItem.style.padding = "5px 15px";
menuItem.style.cursor = "pointer";
menuItem.addEventListener("mouseover", () => {
menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0";
});
menuItem.addEventListener("mouseout", () => {
menuItem.style.backgroundColor = "";
});
menuItem.addEventListener("click", () => {
item.action();
hideFileContextMenu();
});
menu.appendChild(menuItem);
const MENU_ID = 'fileContextMenu';
function qMenu() { return document.getElementById(MENU_ID); }
function setText(btn, key) { btn.querySelector('span').textContent = t(key); }
// One-time: localize labels
function localizeMenu() {
const m = qMenu(); if (!m) return;
const map = {
'create_file': 'create_file',
'delete_selected': 'delete_selected',
'copy_selected': 'copy_selected',
'move_selected': 'move_selected',
'download_zip': 'download_zip',
'extract_zip': 'extract_zip',
'tag_selected': 'tag_selected',
'preview': 'preview',
'edit': 'edit',
'rename': 'rename',
'tag_file': 'tag_file'
};
Object.entries(map).forEach(([action, key]) => {
const el = m.querySelector(`.mi[data-action="${action}"]`);
if (el) setText(el, key);
});
}
menu.style.left = x + "px";
menu.style.top = y + "px";
menu.style.display = "block";
// Show/hide items based on selection state
function configureVisibility({ any, one, many, anyZip, canEdit }) {
const m = qMenu(); if (!m) return;
const menuRect = menu.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (menuRect.bottom > viewportHeight) {
let newTop = viewportHeight - menuRect.height;
if (newTop < 0) newTop = 0;
menu.style.top = newTop + "px";
const show = (sel, on) => sel.forEach(el => el.hidden = !on);
show(m.querySelectorAll('[data-when="always"]'), true);
show(m.querySelectorAll('[data-when="any"]'), any);
show(m.querySelectorAll('[data-when="one"]'), one);
show(m.querySelectorAll('[data-when="many"]'), many);
show(m.querySelectorAll('[data-when="zip"]'), anyZip);
show(m.querySelectorAll('[data-when="can-edit"]'), canEdit);
// Hide separators at edges or duplicates
cleanupSeparators(m);
}
function cleanupSeparators(menu) {
const kids = Array.from(menu.children);
let lastWasSep = true; // leading seps hidden
kids.forEach((el, i) => {
if (el.classList.contains('sep')) {
const hide = lastWasSep || (i === kids.length - 1);
el.hidden = hide || el.hidden; // keep hidden if already hidden by state
lastWasSep = !el.hidden;
} else if (!el.hidden) {
lastWasSep = false;
}
});
}
// Position menu within viewport
function placeMenu(x, y) {
const m = qMenu(); if (!m) return;
// make visible to measure
m.hidden = false;
m.style.left = '0px';
m.style.top = '0px';
// force a max-height via CSS fallback if styles didn't load yet
const pad = 8;
const vh = window.innerHeight, vw = window.innerWidth;
const mh = Math.min(vh - pad*2, 600); // JS fallback limit
m.style.maxHeight = mh + 'px';
// measure now that it's flow-visible
const r0 = m.getBoundingClientRect();
let nx = x, ny = y;
// If it would overflow right, shift left
if (nx + r0.width > vw - pad) nx = Math.max(pad, vw - r0.width - pad);
// If it would overflow bottom, try placing it above the cursor
if (ny + r0.height > vh - pad) {
const above = y - r0.height - 4;
ny = (above >= pad) ? above : Math.max(pad, vh - r0.height - pad);
}
// Guard top/left minimums
nx = Math.max(pad, nx);
ny = Math.max(pad, ny);
m.style.left = `${nx}px`;
m.style.top = `${ny}px`;
}
export function hideFileContextMenu() {
const menu = document.getElementById("fileContextMenu");
if (menu) {
menu.style.display = "none";
}
const m = qMenu();
if (m) m.hidden = true;
}
function currentSelection() {
const checks = Array.from(document.querySelectorAll('#fileList .file-checkbox'));
// checkbox values are ESCAPED names (because buildFileTableRow used safeFileName)
const selectedEsc = checks.filter(cb => cb.checked).map(cb => cb.value);
const escSet = new Set(selectedEsc);
// map back to real file objects by comparing escaped(f.name)
const files = fileData.filter(f => escSet.has(escapeHTML(f.name)));
const any = files.length > 0;
const one = files.length === 1;
const many = files.length > 1;
const anyZip = files.some(f => f.name.toLowerCase().endsWith('.zip'));
const file = one ? files[0] : null;
const canEditFlag = !!(file && canEditFile(file.name));
// also return the raw names if any caller needs them
return {
files, // <— real file objects for modals
all: files.map(f => f.name),
any, one, many, anyZip,
file,
canEdit: canEditFlag
};
}
export function fileListContextMenuHandler(e) {
e.preventDefault();
let row = e.target.closest("tr");
// Check row if needed
const row = e.target.closest('tr');
if (row) {
const checkbox = row.querySelector(".file-checkbox");
if (checkbox && !checkbox.checked) {
checkbox.checked = true;
updateRowHighlight(checkbox);
const cb = row.querySelector('.file-checkbox');
if (cb && !cb.checked) {
cb.checked = true;
updateRowHighlight(cb);
}
}
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
const state = currentSelection();
configureVisibility(state);
placeMenu(e.clientX, e.clientY);
let menuItems = [
{ label: t("create_file"), action: () => openCreateFileModal() },
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
{ label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } }
];
if (selected.some(name => name.toLowerCase().endsWith(".zip"))) {
menuItems.push({
label: t("extract_zip"),
action: () => { handleExtractZipSelected(new Event("click")); }
});
}
if (selected.length > 1) {
menuItems.push({
label: t("tag_selected"),
action: () => {
const files = fileData.filter(f => selected.includes(f.name));
openMultiTagModal(files);
}
});
}
else if (selected.length === 1) {
const file = fileData.find(f => f.name === selected[0]);
menuItems.push({
label: t("preview"),
action: () => {
const folder = window.currentFolder || "root";
previewFile(buildPreviewUrl(folder, file.name), file.name);
}
});
if (canEditFile(file.name)) {
menuItems.push({
label: t("edit"),
action: () => { editFile(selected[0], window.currentFolder); }
});
}
menuItems.push({
label: t("rename"),
action: () => { renameFile(selected[0], window.currentFolder); }
});
menuItems.push({
label: t("tag_file"),
action: () => { openTagModal(file); }
});
}
showFileContextMenu(e.clientX, e.clientY, menuItems);
// Stash for click handlers
window.__filr_ctx_state = state;
}
// --- add near top ---
let __ctxBoundOnce = false;
function docClickClose(ev) {
const m = qMenu(); if (!m || m.hidden) return;
if (!m.contains(ev.target)) hideFileContextMenu();
}
function docKeyClose(ev) {
if (ev.key === 'Escape') hideFileContextMenu();
}
function menuClickDelegate(ev) {
const btn = ev.target.closest('.mi[data-action]');
if (!btn) return;
ev.stopPropagation();
// CLOSE MENU FIRST so it cant overlay the modal
hideFileContextMenu();
const action = btn.dataset.action;
const s = window.__filr_ctx_state || currentSelection();
const folder = window.currentFolder || 'root';
switch (action) {
case 'create_file': openCreateFileModal(); break;
case 'delete_selected': handleDeleteSelected(new Event('click')); break;
case 'copy_selected': handleCopySelected(new Event('click')); break;
case 'move_selected': handleMoveSelected(new Event('click')); break;
case 'download_zip': handleDownloadZipSelected(new Event('click')); break;
case 'extract_zip': handleExtractZipSelected(new Event('click')); break;
case 'tag_selected':
openMultiTagModal(s.files); // s.files are the real file objects
break;
case 'preview':
if (s.file) previewFile(buildPreviewUrl(folder, s.file.name), s.file.name);
break;
case 'edit':
if (s.file && s.canEdit) editFile(s.file.name, folder);
break;
case 'rename':
if (s.file) renameFile(s.file.name, folder);
break;
case 'tag_file':
if (s.file) openTagModal(s.file);
break;
}
}
// keep your renderFileTable wrapper as-is
export function bindFileListContextMenu() {
const fileListContainer = document.getElementById("fileList");
if (fileListContainer) {
fileListContainer.oncontextmenu = fileListContextMenuHandler;
const container = document.getElementById('fileList');
const menu = qMenu();
if (!container || !menu) return;
localizeMenu();
// Open on right click in the table
container.oncontextmenu = fileListContextMenuHandler;
// Bind once
if (!__ctxBoundOnce) {
document.addEventListener('click', docClickClose);
document.addEventListener('keydown', docKeyClose);
menu.addEventListener('click', menuClickDelegate); // handles actions
__ctxBoundOnce = true;
}
}
document.addEventListener("click", function (e) {
const menu = document.getElementById("fileContextMenu");
if (menu && menu.style.display === "block") {
hideFileContextMenu();
}
});
// Rebind context menu after file table render.
// Rebind after table render (keeps your original behavior)
(function () {
const originalRenderFileTable = window.renderFileTable;
window.renderFileTable = function (folder) {
originalRenderFileTable(folder);
bindFileListContextMenu();
};
const orig = window.renderFileTable;
if (typeof orig === 'function') {
window.renderFileTable = function (folder) {
orig(folder);
bindFileListContextMenu();
};
} else {
// If not present yet, bind once DOM is ready
document.addEventListener('DOMContentLoaded', bindFileListContextMenu, { once: true });
}
})();

View File

@@ -160,7 +160,7 @@ function ensureMediaModal() {
const root = document.documentElement;
const styles = getComputedStyle(root);
const isDark = root.classList.contains('dark-mode');
const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#121212' : '#ffffff');
const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#2c2c2c' : '#ffffff');
const textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111');
const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)';
@@ -469,102 +469,118 @@ export function previewFile(fileUrl, fileName) {
return;
}
/* -------------------- VIDEOS -------------------- */
if (isVideo) {
let video = document.createElement("video"); // let so we can rebind
video.controls = true;
video.style.maxWidth = "88vw";
video.style.maxHeight = "88vh";
video.style.objectFit = "contain";
container.appendChild(video);
// Top-right action icons (Material icons, theme-aware)
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
actionWrap.appendChild(markBtnIcon);
actionWrap.appendChild(clearBtnIcon);
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
overlay.mediaType = 'video';
overlay.mediaList = videos;
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); };
const SAVE_INTERVAL_MS = 5000;
let lastSaveAt = 0;
let pending = false;
async function getProgress(nm) {
try {
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
const data = await res.json();
return data && data.state ? data.state : null;
} catch { return null; }
}
async function sendProgress({nm, seconds, duration, completed, clear}) {
try {
pending = true;
const res = await fetch("/api/media/updateProgress.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
});
const data = await res.json();
pending = false;
return data;
} catch (e) { pending = false; console.error(e); return null; }
}
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
function renderStatus(state) {
if (!statusChip) return;
// Completed
if (state && state.completed) {
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
statusChip.style.background = 'rgba(34,197,94,.15)';
statusChip.style.color = '#22c55e';
markBtnIcon.style.display = 'none';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
/* -------------------- VIDEOS -------------------- */
if (isVideo) {
let video = document.createElement("video");
video.controls = true;
video.preload = 'auto'; // hint browser to start fetching quickly
video.style.maxWidth = "88vw";
video.style.maxHeight = "88vh";
video.style.objectFit = "contain";
container.appendChild(video);
// Top-right action icons (Material icons, theme-aware)
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
actionWrap.appendChild(markBtnIcon);
actionWrap.appendChild(clearBtnIcon);
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
overlay.mediaType = 'video';
overlay.mediaList = videos;
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
// Track which file is currently active
let currentName = name;
const setVideoSrc = (nm) => {
currentName = nm;
video.src = buildPreviewUrl(folder, nm);
setTitle(overlay, nm);
};
const SAVE_INTERVAL_MS = 5000;
let lastSaveAt = 0;
let pending = false;
async function getProgress(nm) {
try {
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
const data = await res.json();
return data && data.state ? data.state : null;
} catch { return null; }
}
// In progress
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
statusChip.textContent = `${pct}%`;
statusChip.style.display = 'inline-block';
const dark = document.documentElement.classList.contains('dark-mode');
const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
statusChip.style.color = ORANGE_HEX;
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
async function sendProgress({nm, seconds, duration, completed, clear}) {
try {
pending = true;
const res = await fetch("/api/media/updateProgress.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
});
const data = await res.json();
pending = false;
return data;
} catch (e) {
pending = false;
console.error(e);
return null;
}
}
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
function renderStatus(state) {
if (!statusChip) return;
// Completed
if (state && state.completed) {
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
statusChip.style.background = 'rgba(34,197,94,.15)';
statusChip.style.color = '#22c55e';
markBtnIcon.style.display = 'none';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// In progress
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
statusChip.textContent = `${pct}%`;
statusChip.style.display = 'inline-block';
const dark = document.documentElement.classList.contains('dark-mode');
const ORANGE_HEX = '#ea580c';
statusChip.style.color = ORANGE_HEX;
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// No progress
statusChip.style.display = 'none';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
clearBtnIcon.style.display = 'none';
}
// No progress
statusChip.style.display = 'none';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = 'none';
}
function bindVideoEvents(nm) {
const nv = video.cloneNode(true);
video.replaceWith(nv);
video = nv;
// ---- Event handlers (use currentName instead of rebinding per file) ----
video.addEventListener("loadedmetadata", async () => {
const nm = currentName;
try {
const state = await getProgress(nm);
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
video.currentTime = state.seconds;
const seconds = Math.floor(video.currentTime || 0);
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
setFileProgressBadge(nm, seconds, duration);
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
@@ -577,20 +593,24 @@ export function previewFile(fileUrl, fileName) {
renderStatus(null);
}
});
video.addEventListener("timeupdate", async () => {
const now = Date.now();
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
lastSaveAt = now;
const seconds = Math.floor(video.currentTime || 0);
const nm = currentName;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
sendProgress({ nm, seconds, duration });
setFileProgressBadge(nm, seconds, duration);
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
renderStatus({ seconds, duration, completed: false });
});
video.addEventListener("ended", async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
@@ -598,50 +618,54 @@ export function previewFile(fileUrl, fileName) {
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
});
markBtnIcon.onclick = async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
};
clearBtnIcon.onclick = async () => {
const nm = currentName;
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("progress_cleared") || "Progress cleared");
setFileWatchedBadge(nm, false);
renderStatus(null);
};
}
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const nm = overlay.mediaList[overlay.mediaIndex].name;
setVideoSrc(nm);
bindVideoEvents(nm);
};
if (videos.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
const onKey = (e) => {
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
if (e.key === "ArrowLeft") navigate(-1);
if (e.key === "ArrowRight") navigate(+1);
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const nm = overlay.mediaList[overlay.mediaIndex].name;
setVideoSrc(nm);
renderStatus(null);
};
window.addEventListener("keydown", onKey);
overlay._onKey = onKey;
if (videos.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
const onKey = (e) => {
if (!document.body.contains(overlay)) {
window.removeEventListener("keydown", onKey);
return;
}
if (e.key === "ArrowLeft") navigate(-1);
if (e.key === "ArrowRight") navigate(+1);
};
window.addEventListener("keydown", onKey);
overlay._onKey = onKey;
}
setVideoSrc(name);
renderStatus(null);
overlay.style.display = "flex";
return;
}
setVideoSrc(name);
renderStatus(null);
bindVideoEvents(name);
overlay.style.display = "flex";
return;
}
/* -------------------- AUDIO / OTHER -------------------- */
if (isAudio) {
const audio = document.createElement("audio");

View File

@@ -1,172 +1,214 @@
// fileTags.js
// This module provides functions for opening the tag modal,
// adding tags to files (with a global tag store for reuse),
// updating the file row display with tag badges,
// filtering the file list by tag, and persisting tag data.
// fileTags.js (drop-in fix: single-instance modals, idempotent bindings)
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}';
export function openTagModal(file) {
// Create the modal element.
let modal = document.createElement('div');
modal.id = 'tagModal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="width: 450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="
margin:0;
display:inline-block;
max-width: calc(100% - 40px);
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
">
${t("tag_file")}: ${escapeHTML(file.name)}
</h3>
<span id="closeTagModal" class="editor-close-btn">&times;</span>
</div>
<div class="modal-body" style="margin-top:10px;">
<label for="tagNameInput">${t("tag_name")}</label>
<input type="text" id="tagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
<br><br>
<label for="tagColorInput">${t("tag_name")}</label>
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br>
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
<!-- Custom tag options will be populated here -->
</div>
<br>
<div style="text-align:right;">
<button id="saveTagBtn" class="btn btn-primary">${t("save_tag")}</button>
</div>
<div id="currentTags" style="margin-top:10px; font-size:0.9em;">
<!-- Existing tags will be listed here -->
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
// -------------------- state --------------------
let __singleInit = false;
let __multiInit = false;
let currentFile = null;
updateCustomTagDropdown();
document.getElementById('closeTagModal').addEventListener('click', () => {
modal.remove();
});
updateTagModalDisplay(file);
document.getElementById('tagNameInput').addEventListener('input', (e) => {
updateCustomTagDropdown(e.target.value);
});
document.getElementById('saveTagBtn').addEventListener('click', () => {
const tagName = document.getElementById('tagNameInput').value.trim();
const tagColor = document.getElementById('tagColorInput').value;
if (!tagName) {
alert('Please enter a tag name.');
return;
}
addTagToFile(file, { name: tagName, color: tagColor });
updateTagModalDisplay(file);
updateFileRowTagDisplay(file);
saveFileTags(file);
if (window.viewMode === 'gallery') {
renderGalleryView(window.currentFolder);
} else {
renderFileTable(window.currentFolder);
}
document.getElementById('tagNameInput').value = '';
updateCustomTagDropdown();
});
// Global store (preserve existing behavior)
window.globalTags = window.globalTags || [];
if (localStorage.getItem('globalTags')) {
try { window.globalTags = JSON.parse(localStorage.getItem('globalTags')); } catch (e) {}
}
/**
* Open a modal to tag multiple files.
* @param {Array} files - Array of file objects to tag.
*/
export function openMultiTagModal(files) {
let modal = document.createElement('div');
modal.id = 'multiTagModal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="width: 450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3>
<span id="closeMultiTagModal" class="editor-close-btn">&times;</span>
</div>
<div class="modal-body" style="margin-top:10px;">
<label for="multiTagNameInput">Tag Name:</label>
<input type="text" id="multiTagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
<br><br>
<label for="multiTagColorInput">Tag Color:</label>
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br>
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
<!-- Custom tag options will be populated here -->
</div>
<br>
<div style="text-align:right;">
<button id="saveMultiTagBtn" class="btn btn-primary">Save Tag to Selected</button>
// -------------------- ensure DOM (create-once-if-missing) --------------------
function ensureSingleTagModal() {
// de-dupe if something already injected multiples
const all = document.querySelectorAll('#tagModal');
if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
let modal = document.getElementById('tagModal');
if (!modal) {
document.body.insertAdjacentHTML('beforeend', `
<div id="tagModal" class="modal" style="display:none">
<div class="modal-content" style="width:450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 id="tagModalTitle" style="margin:0; max-width:calc(100% - 40px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
${t('tag_file')}
</h3>
<span id="closeTagModal" class="editor-close-btn">×</span>
</div>
<div class="modal-body" style="margin-top:10px;">
<label for="tagNameInput">${t('tag_name')}</label>
<input type="text" id="tagNameInput" placeholder="${t('tag_name')}" style="width:100%; padding:5px;"/>
<br><br>
<label for="tagColorInput">${t('tag_color') || 'Tag Color'}</label>
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br>
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
<br>
<div style="text-align:right;">
<button id="saveTagBtn" class="btn btn-primary" type="button">${t('save_tag')}</button>
</div>
<div id="currentTags" style="margin-top:10px; font-size:.9em;"></div>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
`);
modal = document.getElementById('tagModal');
}
return modal;
}
updateMultiCustomTagDropdown();
function ensureMultiTagModal() {
const all = document.querySelectorAll('#multiTagModal');
if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
document.getElementById('closeMultiTagModal').addEventListener('click', () => {
modal.remove();
let modal = document.getElementById('multiTagModal');
if (!modal) {
document.body.insertAdjacentHTML('beforeend', `
<div id="multiTagModal" class="modal" style="display:none">
<div class="modal-content" style="width:450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 id="multiTagTitle" style="margin:0;"></h3>
<span id="closeMultiTagModal" class="editor-close-btn">×</span>
</div>
<div class="modal-body" style="margin-top:10px;">
<label for="multiTagNameInput">${t('tag_name')}</label>
<input type="text" id="multiTagNameInput" placeholder="${t('tag_name')}" style="width:100%; padding:5px;"/>
<br><br>
<label for="multiTagColorInput">${t('tag_color') || 'Tag Color'}</label>
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br>
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
<br>
<div style="text-align:right;">
<button id="saveMultiTagBtn" class="btn btn-primary" type="button">${t('save_tag') || 'Save Tag'}</button>
</div>
</div>
</div>
</div>
`);
modal = document.getElementById('multiTagModal');
}
return modal;
}
// -------------------- init (bind once) --------------------
function initSingleModalOnce() {
if (__singleInit) return;
const modal = ensureSingleTagModal();
const closeBtn = document.getElementById('closeTagModal');
const saveBtn = document.getElementById('saveTagBtn');
const nameInp = document.getElementById('tagNameInput');
// Close handlers
closeBtn?.addEventListener('click', hideTagModal);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTagModal(); });
modal.addEventListener('click', (e) => {
if (e.target === modal) hideTagModal(); // click backdrop
});
document.getElementById('multiTagNameInput').addEventListener('input', (e) => {
updateMultiCustomTagDropdown(e.target.value);
// Input filter for dropdown
nameInp?.addEventListener('input', (e) => updateCustomTagDropdown(e.target.value));
// Save handler
saveBtn?.addEventListener('click', () => {
const tagName = (document.getElementById('tagNameInput')?.value || '').trim();
const tagColor = document.getElementById('tagColorInput')?.value || '#ff0000';
if (!tagName) { alert(t('enter_tag_name') || 'Please enter a tag name.'); return; }
if (!currentFile) return;
addTagToFile(currentFile, { name: tagName, color: tagColor });
updateTagModalDisplay(currentFile);
updateFileRowTagDisplay(currentFile);
saveFileTags(currentFile);
if (window.viewMode === 'gallery') renderGalleryView(window.currentFolder);
else renderFileTable(window.currentFolder);
const inp = document.getElementById('tagNameInput');
if (inp) inp.value = '';
updateCustomTagDropdown('');
});
document.getElementById('saveMultiTagBtn').addEventListener('click', () => {
const tagName = document.getElementById('multiTagNameInput').value.trim();
const tagColor = document.getElementById('multiTagColorInput').value;
if (!tagName) {
alert('Please enter a tag name.');
return;
}
__singleInit = true;
}
function initMultiModalOnce() {
if (__multiInit) return;
const modal = ensureMultiTagModal();
const closeBtn = document.getElementById('closeMultiTagModal');
const saveBtn = document.getElementById('saveMultiTagBtn');
const nameInp = document.getElementById('multiTagNameInput');
closeBtn?.addEventListener('click', hideMultiTagModal);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideMultiTagModal(); });
modal.addEventListener('click', (e) => {
if (e.target === modal) hideMultiTagModal();
});
nameInp?.addEventListener('input', (e) => updateMultiCustomTagDropdown(e.target.value));
saveBtn?.addEventListener('click', () => {
const tagName = (document.getElementById('multiTagNameInput')?.value || '').trim();
const tagColor = document.getElementById('multiTagColorInput')?.value || '#ff0000';
if (!tagName) { alert(t('enter_tag_name') || 'Please enter a tag name.'); return; }
const files = (window.__multiTagFiles || []);
files.forEach(file => {
addTagToFile(file, { name: tagName, color: tagColor });
updateFileRowTagDisplay(file);
saveFileTags(file);
});
modal.remove();
if (window.viewMode === 'gallery') {
renderGalleryView(window.currentFolder);
} else {
renderFileTable(window.currentFolder);
}
hideMultiTagModal();
if (window.viewMode === 'gallery') renderGalleryView(window.currentFolder);
else renderFileTable(window.currentFolder);
});
__multiInit = true;
}
/**
* Update the custom dropdown for multi-tag modal.
* Similar to updateCustomTagDropdown but includes a remove icon.
*/
// -------------------- open/close APIs --------------------
export function openTagModal(file) {
initSingleModalOnce();
const modal = document.getElementById('tagModal');
const title = document.getElementById('tagModalTitle');
currentFile = file || null;
if (title) title.textContent = `${t('tag_file')}: ${file ? escapeHTML(file.name) : ''}`;
updateCustomTagDropdown('');
updateTagModalDisplay(file);
modal.style.display = 'block';
}
export function hideTagModal() {
const modal = document.getElementById('tagModal');
if (modal) modal.style.display = 'none';
}
export function openMultiTagModal(files) {
initMultiModalOnce();
const modal = document.getElementById('multiTagModal');
const title = document.getElementById('multiTagTitle');
window.__multiTagFiles = Array.isArray(files) ? files : [];
if (title) title.textContent = `${t('tag_selected') || 'Tag Selected'} (${window.__multiTagFiles.length})`;
updateMultiCustomTagDropdown('');
modal.style.display = 'block';
}
export function hideMultiTagModal() {
const modal = document.getElementById('multiTagModal');
if (modal) modal.style.display = 'none';
}
// -------------------- dropdown + UI helpers --------------------
function updateMultiCustomTagDropdown(filterText = "") {
const dropdown = document.getElementById("multiCustomTagDropdown");
if (!dropdown) return;
dropdown.innerHTML = "";
let tags = window.globalTags || [];
if (filterText) {
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
}
if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
if (tags.length > 0) {
tags.forEach(tag => {
const item = document.createElement("div");
item.style.cursor = "pointer";
item.style.padding = "5px";
item.style.borderBottom = "1px solid #eee";
// Display colored square and tag name with remove icon.
item.innerHTML = `
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
${escapeHTML(tag.name)}
@@ -174,8 +216,10 @@ function updateMultiCustomTagDropdown(filterText = "") {
`;
item.addEventListener("click", function(e) {
if (e.target.classList.contains("global-remove")) return;
document.getElementById("multiTagNameInput").value = tag.name;
document.getElementById("multiTagColorInput").value = tag.color;
const n = document.getElementById("multiTagNameInput");
const c = document.getElementById("multiTagColorInput");
if (n) n.value = tag.name;
if (c) c.value = tag.color;
});
item.querySelector('.global-remove').addEventListener("click", function(e){
e.stopPropagation();
@@ -184,7 +228,7 @@ function updateMultiCustomTagDropdown(filterText = "") {
dropdown.appendChild(item);
});
} else {
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>";
dropdown.innerHTML = `<div style="padding:5px;">${t('no_tags_available') || 'No tags available'}</div>`;
}
}
@@ -193,9 +237,7 @@ function updateCustomTagDropdown(filterText = "") {
if (!dropdown) return;
dropdown.innerHTML = "";
let tags = window.globalTags || [];
if (filterText) {
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
}
if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
if (tags.length > 0) {
tags.forEach(tag => {
const item = document.createElement("div");
@@ -209,8 +251,10 @@ function updateCustomTagDropdown(filterText = "") {
`;
item.addEventListener("click", function(e){
if (e.target.classList.contains('global-remove')) return;
document.getElementById("tagNameInput").value = tag.name;
document.getElementById("tagColorInput").value = tag.color;
const n = document.getElementById("tagNameInput");
const c = document.getElementById("tagColorInput");
if (n) n.value = tag.name;
if (c) c.value = tag.color;
});
item.querySelector('.global-remove').addEventListener("click", function(e){
e.stopPropagation();
@@ -219,16 +263,16 @@ function updateCustomTagDropdown(filterText = "") {
dropdown.appendChild(item);
});
} else {
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>";
dropdown.innerHTML = `<div style="padding:5px;">${t('no_tags_available') || 'No tags available'}</div>`;
}
}
// Update the modal display to show current tags on the file.
function updateTagModalDisplay(file) {
const container = document.getElementById('currentTags');
if (!container) return;
container.innerHTML = '<strong>Current Tags:</strong> ';
if (file.tags && file.tags.length > 0) {
container.innerHTML = `<strong>${t('current_tags') || 'Current Tags'}:</strong> `;
if (file?.tags?.length) {
file.tags.forEach(tag => {
const tagElem = document.createElement('span');
tagElem.textContent = tag.name;
@@ -239,102 +283,65 @@ function updateTagModalDisplay(file) {
tagElem.style.borderRadius = '3px';
tagElem.style.display = 'inline-block';
tagElem.style.position = 'relative';
const removeIcon = document.createElement('span');
removeIcon.textContent = ' ✕';
removeIcon.style.fontWeight = 'bold';
removeIcon.style.marginLeft = '3px';
removeIcon.style.cursor = 'pointer';
removeIcon.addEventListener('click', (e) => {
e.stopPropagation();
removeTagFromFile(file, tag.name);
});
tagElem.appendChild(removeIcon);
container.appendChild(tagElem);
});
} else {
container.innerHTML += 'None';
container.innerHTML += (t('none') || 'None');
}
}
function removeTagFromFile(file, tagName) {
file.tags = file.tags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
file.tags = (file.tags || []).filter(tg => tg.name.toLowerCase() !== tagName.toLowerCase());
updateTagModalDisplay(file);
updateFileRowTagDisplay(file);
saveFileTags(file);
}
/**
* Remove a tag from the global tag store.
* This function updates window.globalTags and calls the backend endpoint
* to remove the tag from the persistent store.
*/
function removeGlobalTag(tagName) {
window.globalTags = window.globalTags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
window.globalTags = (window.globalTags || []).filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
saveGlobalTagRemoval(tagName);
}
// NEW: Save global tag removal to the server.
function saveGlobalTagRemoval(tagName) {
fetch("/api/file/saveFileTag.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({
folder: "root",
file: "global",
deleteGlobal: true,
tagToDelete: tagName,
tags: []
})
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder: "root", file: "global", deleteGlobal: true, tagToDelete: tagName, tags: [] })
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log("Global tag removed:", tagName);
if (data.globalTags) {
window.globalTags = data.globalTags;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
}
} else {
console.error("Error removing global tag:", data.error);
}
})
.catch(err => {
console.error("Error removing global tag:", err);
});
.then(r => r.json())
.then(data => {
if (data.success && data.globalTags) {
window.globalTags = data.globalTags;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
} else if (!data.success) {
console.error("Error removing global tag:", data.error);
}
})
.catch(err => console.error("Error removing global tag:", err));
}
// Global store for reusable tags.
window.globalTags = window.globalTags || [];
if (localStorage.getItem('globalTags')) {
try {
window.globalTags = JSON.parse(localStorage.getItem('globalTags'));
} catch (e) { }
}
// New function to load global tags from the server's persistent JSON.
// -------------------- exports kept from your original --------------------
export function loadGlobalTags() {
fetch("/api/file/getFileTag.php", { credentials: "include" })
.then(response => {
if (!response.ok) {
// If the file doesn't exist, assume there are no global tags.
return [];
}
return response.json();
})
.then(r => r.ok ? r.json() : [])
.then(data => {
window.globalTags = data;
window.globalTags = data || [];
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
@@ -346,142 +353,113 @@ export function loadGlobalTags() {
updateMultiCustomTagDropdown();
});
}
loadGlobalTags();
// Add (or update) a tag in the file object.
export function addTagToFile(file, tag) {
if (!file.tags) {
file.tags = [];
}
const exists = file.tags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
if (exists) {
exists.color = tag.color;
} else {
file.tags.push(tag);
}
const globalExists = window.globalTags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
if (!file.tags) file.tags = [];
const exists = file.tags.find(tg => tg.name.toLowerCase() === tag.name.toLowerCase());
if (exists) exists.color = tag.color; else file.tags.push(tag);
const globalExists = (window.globalTags || []).find(tg => tg.name.toLowerCase() === tag.name.toLowerCase());
if (!globalExists) {
window.globalTags.push(tag);
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
}
}
// Update the file row (in table view) to show tag badges.
export function updateFileRowTagDisplay(file) {
const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`);
console.log('Updating tags for rows:', rows);
rows.forEach(row => {
let cell = row.querySelector('.file-name-cell');
if (cell) {
let badgeContainer = cell.querySelector('.tag-badges');
if (!badgeContainer) {
badgeContainer = document.createElement('div');
badgeContainer.className = 'tag-badges';
badgeContainer.style.display = 'inline-block';
badgeContainer.style.marginLeft = '5px';
cell.appendChild(badgeContainer);
}
badgeContainer.innerHTML = '';
if (file.tags && file.tags.length > 0) {
file.tags.forEach(tag => {
const badge = document.createElement('span');
badge.textContent = tag.name;
badge.style.backgroundColor = tag.color;
badge.style.color = '#fff';
badge.style.padding = '2px 4px';
badge.style.marginRight = '2px';
badge.style.borderRadius = '3px';
badge.style.fontSize = '0.8em';
badge.style.verticalAlign = 'middle';
badgeContainer.appendChild(badge);
});
}
if (!cell) return;
let badgeContainer = cell.querySelector('.tag-badges');
if (!badgeContainer) {
badgeContainer = document.createElement('div');
badgeContainer.className = 'tag-badges';
badgeContainer.style.display = 'inline-block';
badgeContainer.style.marginLeft = '5px';
cell.appendChild(badgeContainer);
}
badgeContainer.innerHTML = '';
(file.tags || []).forEach(tag => {
const badge = document.createElement('span');
badge.textContent = tag.name;
badge.style.backgroundColor = tag.color;
badge.style.color = '#fff';
badge.style.padding = '2px 4px';
badge.style.marginRight = '2px';
badge.style.borderRadius = '3px';
badge.style.fontSize = '0.8em';
badge.style.verticalAlign = 'middle';
badgeContainer.appendChild(badge);
});
});
}
export function initTagSearch() {
const searchInput = document.getElementById('searchInput');
if (searchInput) {
let tagSearchInput = document.getElementById('tagSearchInput');
if (!tagSearchInput) {
tagSearchInput = document.createElement('input');
tagSearchInput.id = 'tagSearchInput';
tagSearchInput.placeholder = 'Filter by tag';
tagSearchInput.style.marginLeft = '10px';
tagSearchInput.style.padding = '5px';
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
tagSearchInput.addEventListener('input', () => {
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
if (window.currentFolder) {
renderFileTable(window.currentFolder);
}
});
}
}
}
export function filterFilesByTag(files) {
if (window.currentTagFilter && window.currentTagFilter !== '') {
return files.filter(file => {
if (file.tags && file.tags.length > 0) {
return file.tags.some(tag => tag.name.toLowerCase().includes(window.currentTagFilter));
}
return false;
if (!searchInput) return;
let tagSearchInput = document.getElementById('tagSearchInput');
if (!tagSearchInput) {
tagSearchInput = document.createElement('input');
tagSearchInput.id = 'tagSearchInput';
tagSearchInput.placeholder = t('filter_by_tag') || 'Filter by tag';
tagSearchInput.style.marginLeft = '10px';
tagSearchInput.style.padding = '5px';
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
tagSearchInput.addEventListener('input', () => {
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
if (window.currentFolder) renderFileTable(window.currentFolder);
});
}
return files;
}
export function filterFilesByTag(files) {
const q = (window.currentTagFilter || '').trim().toLowerCase();
if (!q) return files;
return files.filter(file => (file.tags || []).some(tag => tag.name.toLowerCase().includes(q)));
}
function updateGlobalTagList() {
const dataList = document.getElementById("globalTagList");
if (dataList) {
dataList.innerHTML = "";
window.globalTags.forEach(tag => {
const option = document.createElement("option");
option.value = tag.name;
dataList.appendChild(option);
});
}
if (!dataList) return;
dataList.innerHTML = "";
(window.globalTags || []).forEach(tag => {
const option = document.createElement("option");
option.value = tag.name;
dataList.appendChild(option);
});
}
export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
const folder = file.folder || "root";
const payload = {
folder: folder,
file: file.name,
tags: file.tags
};
if (deleteGlobal && tagToDelete) {
payload.file = "global";
payload.deleteGlobal = true;
payload.tagToDelete = tagToDelete;
}
const payload = deleteGlobal && tagToDelete ? {
folder: "root",
file: "global",
deleteGlobal: true,
tagToDelete,
tags: []
} : { folder, file: file.name, tags: file.tags };
fetch("/api/file/saveFileTag.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(r => r.json())
.then(data => {
if (data.success) {
console.log("Tags saved:", data);
if (data.globalTags) {
window.globalTags = data.globalTags;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
}
updateGlobalTagList();
} else {
console.error("Error saving tags:", data.error);
}
})
.catch(err => {
console.error("Error saving tags:", err);
});
.catch(err => console.error("Error saving tags:", err));
}

File diff suppressed because it is too large Load Diff

View File

@@ -318,7 +318,20 @@ const translations = {
"reset_default": "Reset",
"save_color": "Save",
"folder_color_saved": "Folder color saved.",
"folder_color_cleared": "Folder color reset."
"folder_color_cleared": "Folder color reset.",
"load_more": "Load more",
"loading": "Loading...",
"no_access": "You do not have access to this resource.",
"please_select_valid_folder": "Please select a valid folder.",
"folder_help_click_view": "Click a folder in the tree to view its files.",
"folder_help_expand_chevrons": "Use chevrons to expand/collapse. Locked folders (padlock) can expand but cant be opened.",
"folder_help_context_menu": "Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.",
"folder_help_drag_drop": "Drag a folder onto another folder or a breadcrumb to move it.",
"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.",
"load_more_folders": "Load More Folders"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -406,6 +406,40 @@ function bindDarkMode() {
// 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
}
// --- Login options (apply in BOTH phases so login page is correct) ---
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};

View File

@@ -6,6 +6,176 @@ import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
// --- Lightweight tracking of in-progress resumable uploads (per user) ---
const RESUMABLE_DRAFTS_KEY = 'filr_resumable_drafts_v1';
function getCurrentUserKey() {
// Try a few globals; fall back to browser profile
const u =
(window.currentUser && String(window.currentUser)) ||
(window.appUser && String(window.appUser)) ||
(window.username && String(window.username)) ||
'';
return u || 'anon';
}
function loadResumableDraftsAll() {
try {
const raw = localStorage.getItem(RESUMABLE_DRAFTS_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return (parsed && typeof parsed === 'object') ? parsed : {};
} catch (e) {
console.warn('Failed to read resumable drafts from localStorage', e);
return {};
}
}
function saveResumableDraftsAll(all) {
try {
localStorage.setItem(RESUMABLE_DRAFTS_KEY, JSON.stringify(all));
} catch (e) {
console.warn('Failed to persist resumable drafts to localStorage', e);
}
}
function getUserDraftContext() {
const all = loadResumableDraftsAll();
const userKey = getCurrentUserKey();
if (!all[userKey] || typeof all[userKey] !== 'object') {
all[userKey] = {};
}
const drafts = all[userKey];
return { all, userKey, drafts };
}
// Upsert / update a record for this resumable file
function upsertResumableDraft(file, percent) {
if (!file || !file.uniqueIdentifier) return;
const { all, userKey, drafts } = getUserDraftContext();
const id = file.uniqueIdentifier;
const folder = window.currentFolder || 'root';
const name = file.fileName || file.name || 'Unnamed file';
const size = file.size || 0;
const prev = drafts[id] || {};
const p = Math.max(0, Math.min(100, Math.floor(percent || 0)));
// Avoid hammering localStorage if nothing substantially changed
if (prev.lastPercent !== undefined && Math.abs(p - prev.lastPercent) < 1) {
return;
}
drafts[id] = {
identifier: id,
fileName: name,
size,
folder,
lastPercent: p,
updatedAt: Date.now()
};
all[userKey] = drafts;
saveResumableDraftsAll(all);
}
// Remove a single draft by identifier
function clearResumableDraft(identifier) {
if (!identifier) return;
const { all, userKey, drafts } = getUserDraftContext();
if (drafts[identifier]) {
delete drafts[identifier];
all[userKey] = drafts;
saveResumableDraftsAll(all);
}
}
// Optionally clear all drafts for the current folder (used on full success)
function clearResumableDraftsForFolder(folder) {
const { all, userKey, drafts } = getUserDraftContext();
const f = folder || 'root';
let changed = false;
for (const [id, rec] of Object.entries(drafts)) {
if (!rec || typeof rec !== 'object') continue;
if (rec.folder === f) {
delete drafts[id];
changed = true;
}
}
if (changed) {
all[userKey] = drafts;
saveResumableDraftsAll(all);
}
}
// Show a small banner if there is any in-progress resumable upload for this folder
function showResumableDraftBanner() {
const uploadCard = document.getElementById('uploadCard');
if (!uploadCard) return;
// Remove any existing banner first
const existing = document.getElementById('resumableDraftBanner');
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing);
}
const { drafts } = getUserDraftContext();
const folder = window.currentFolder || 'root';
const candidates = Object.values(drafts)
.filter(d =>
d &&
d.folder === folder &&
typeof d.lastPercent === 'number' &&
d.lastPercent > 0 &&
d.lastPercent < 100
)
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
if (!candidates.length) {
return; // nothing to show
}
const latest = candidates[0];
const count = candidates.length;
const countText =
count === 1
? 'You have a partially uploaded file'
: `You have ${count} partially uploaded files. Latest:`;
const banner = document.createElement('div');
banner.id = 'resumableDraftBanner';
banner.className = 'upload-resume-banner';
banner.innerHTML = `
<div class="upload-resume-banner-inner">
<span class="material-icons" style="vertical-align:middle;margin-right:6px;">cloud_upload</span>
<span class="upload-resume-text">
${countText}
<strong>${escapeHTML(latest.fileName)}</strong>
(~${latest.lastPercent}%).
Choose it again from your device to resume.
</span>
<button type="button" class="upload-resume-dismiss-btn">Dismiss</button>
</div>
`;
const dismissBtn = banner.querySelector('.upload-resume-dismiss-btn');
if (dismissBtn) {
dismissBtn.addEventListener('click', () => {
// Clear all resumable hints for this folder when the user dismisses.
clearResumableDraftsForFolder(folder);
if (banner.parentNode) {
banner.parentNode.removeChild(banner);
}
});
}
// Insert at top of uploadCard
uploadCard.insertBefore(banner, uploadCard.firstChild);
}
/* -----------------------------------------------------
Helpers for DragandDrop Folder Uploads (Original Code)
----------------------------------------------------- */
@@ -456,7 +626,7 @@ async function initResumableUpload() {
chunkSize: 1.5 * 1024 * 1024,
simultaneousUploads: 3,
forceChunkSize: true,
testChunks: false,
testChunks: true,
withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken },
query: () => ({
@@ -493,6 +663,11 @@ 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.
@@ -520,8 +695,40 @@ async function initResumableUpload() {
resumableInstance.on("fileProgress", function (file) {
const progress = file.progress(); // value between 0 and 1
const percent = Math.floor(progress * 100);
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
let percent = Math.floor(progress * 100);
// Never persist a full 100% from progress alone.
// If the tab dies here, we still want it to look resumable.
if (percent >= 100) percent = 99;
const li = document.querySelector(
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
);
if (li && li.progressBar) {
if (percent < 99) {
li.progressBar.style.width = percent + "%";
const elapsed = (Date.now() - li.startTime) / 1000;
let speed = "";
if (elapsed > 0) {
const bytesUploaded = progress * file.size;
const spd = bytesUploaded / elapsed;
if (spd < 1024) speed = spd.toFixed(0) + " B/s";
else if (spd < 1048576) speed = (spd / 1024).toFixed(1) + " KB/s";
else speed = (spd / 1048576).toFixed(1) + " MB/s";
}
li.progressBar.innerText = percent + "% (" + speed + ")";
} else {
li.progressBar.style.width = "100%";
li.progressBar.innerHTML =
'<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
}
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) {
pauseResumeBtn.disabled = false;
}
}
if (li && li.progressBar) {
if (percent < 99) {
li.progressBar.style.width = percent + "%";
@@ -553,6 +760,7 @@ async function initResumableUpload() {
pauseResumeBtn.disabled = false;
}
}
upsertResumableDraft(file, percent);
});
resumableInstance.on("fileSuccess", function (file, message) {
@@ -591,6 +799,9 @@ async function initResumableUpload() {
}
refreshFolderIcon(window.currentFolder);
loadFileList(window.currentFolder);
// This file finished successfully, remove its draft record
clearResumableDraft(file.uniqueIdentifier);
showResumableDraftBanner();
});
@@ -608,18 +819,22 @@ async function initResumableUpload() {
pauseResumeBtn.disabled = false;
}
showToast("Error uploading file: " + file.fileName);
// Treat errored file as no longer resumable (for now) and clear its hint
showResumableDraftBanner();
});
resumableInstance.on("complete", function () {
resumableInstance.on("complete", function () {
// If any file is marked with an error, leave the list intact.
const hasError = window.selectedFiles.some(f => f.isError);
const hasError = Array.isArray(window.selectedFiles) && window.selectedFiles.some(f => f.isError);
if (!hasError) {
// All files succeeded—clear the file input and progress container after 5 seconds.
setTimeout(() => {
const fileInput = document.getElementById("file");
if (fileInput) fileInput.value = "";
const progressContainer = document.getElementById("uploadProgressContainer");
progressContainer.innerHTML = "";
if (progressContainer) {
progressContainer.innerHTML = "";
}
window.selectedFiles = [];
adjustFolderHelpExpansionClosed();
const fileInfoContainer = document.getElementById("fileInfoContainer");
@@ -628,6 +843,15 @@ async function initResumableUpload() {
}
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault();
// IMPORTANT: clear Resumable's internal file list so the next upload
// doesn't think there are still resumable files queued.
if (resumableInstance) {
// cancel() after completion just resets internal state; no chunks are deleted server-side.
resumableInstance.cancel();
}
clearResumableDraftsForFolder(window.currentFolder || 'root');
showResumableDraftBanner();
}, 5000);
} else {
showToast("Some files failed to upload. Please check the list.");
@@ -651,11 +875,34 @@ function submitFiles(allFiles) {
const f = window.currentFolder || "root";
try { return decodeURIComponent(f); } catch { return f; }
})();
const progressContainer = document.getElementById("uploadProgressContainer");
const fileInput = document.getElementById("file");
if (!progressContainer) {
console.warn("submitFiles called but #uploadProgressContainer not found");
return;
}
// --- Ensure there are progress list items for these files ---
let listItems = progressContainer.querySelectorAll("li.upload-progress-item");
if (!listItems.length) {
// Guarantee each file has a stable uploadIndex
allFiles.forEach((file, index) => {
if (file.uploadIndex === undefined || file.uploadIndex === null) {
file.uploadIndex = index;
}
});
// Build the UI rows for these files
// This will also set window.selectedFiles and fileInfoContainer, etc.
processFiles(allFiles);
// Re-query now that processFiles has populated the DOM
listItems = progressContainer.querySelectorAll("li.upload-progress-item");
}
const progressElements = {};
const listItems = progressContainer.querySelectorAll("li.upload-progress-item");
listItems.forEach(item => {
progressElements[item.dataset.uploadIndex] = item;
});
@@ -681,7 +928,7 @@ function submitFiles(allFiles) {
if (e.lengthComputable) {
currentPercent = Math.round((e.loaded / e.total) * 100);
const li = progressElements[file.uploadIndex];
if (li) {
if (li && li.progressBar) {
const elapsed = (Date.now() - li.startTime) / 1000;
let speed = "";
if (elapsed > 0) {
@@ -717,12 +964,12 @@ function submitFiles(allFiles) {
return; // skip the "finishedCount++" and error/success logic for now
}
// ─── Normal success/error handling ────────────────────────────
// ─── Normal success/error handling ────────────────────────────
const li = progressElements[file.uploadIndex];
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
// real success
if (li) {
if (li && li.progressBar) {
li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done";
if (li.removeBtn) li.removeBtn.style.display = "none";
@@ -731,39 +978,40 @@ function submitFiles(allFiles) {
} else {
// real failure
if (li) {
if (li && li.progressBar) {
li.progressBar.innerText = "Error";
}
allSucceeded = false;
}
if (file.isClipboard) {
setTimeout(() => {
window.selectedFiles = [];
updateFileInfoCount();
const progressContainer = document.getElementById("uploadProgressContainer");
if (progressContainer) progressContainer.innerHTML = "";
const fileInfoContainer = document.getElementById("fileInfoContainer");
if (fileInfoContainer) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
const pc = document.getElementById("uploadProgressContainer");
if (pc) pc.innerHTML = "";
const fic = document.getElementById("fileInfoContainer");
if (fic) {
fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
}
}, 5000);
}
// ─── Only now count this chunk as finished ───────────────────
// ─── Only now count this upload as finished ───────────────────
finishedCount++;
if (finishedCount === allFiles.length) {
const succeededCount = uploadResults.filter(Boolean).length;
const failedCount = allFiles.length - succeededCount;
if (finishedCount === allFiles.length) {
const succeededCount = uploadResults.filter(Boolean).length;
const failedCount = allFiles.length - succeededCount;
setTimeout(() => {
refreshFileList(allFiles, uploadResults, progressElements);
}, 250);
}
setTimeout(() => {
refreshFileList(allFiles, uploadResults, progressElements);
}, 250);
}
});
xhr.addEventListener("error", function () {
const li = progressElements[file.uploadIndex];
if (li) {
if (li && li.progressBar) {
li.progressBar.innerText = "Error";
}
uploadResults[file.uploadIndex] = false;
@@ -779,7 +1027,7 @@ if (finishedCount === allFiles.length) {
xhr.addEventListener("abort", function () {
const li = progressElements[file.uploadIndex];
if (li) {
if (li && li.progressBar) {
li.progressBar.innerText = "Aborted";
}
uploadResults[file.uploadIndex] = false;
@@ -809,38 +1057,42 @@ if (finishedCount === allFiles.length) {
})
.map(s => s.trim().toLowerCase())
.filter(Boolean);
let overallSuccess = true;
let succeeded = 0;
allFiles.forEach(file => {
const clientFileName = file.name.trim().toLowerCase();
const li = progressElements[file.uploadIndex];
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
if (!uploadResults[file.uploadIndex] || (!hadRelative && !serverFiles.includes(clientFileName))) {
if (li) {
if (!uploadResults[file.uploadIndex] ||
(!hadRelative && !serverFiles.includes(clientFileName))) {
if (li && li.progressBar) {
li.progressBar.innerText = "Error";
}
overallSuccess = false;
} else if (li) {
succeeded++;
// Schedule removal of successful file entry after 5 seconds.
setTimeout(() => {
li.remove();
delete progressElements[file.uploadIndex];
updateFileInfoCount();
const progressContainer = document.getElementById("uploadProgressContainer");
if (progressContainer && progressContainer.querySelectorAll("li.upload-progress-item").length === 0) {
const fileInput = document.getElementById("file");
if (fileInput) fileInput.value = "";
progressContainer.innerHTML = "";
const pc = document.getElementById("uploadProgressContainer");
if (pc && pc.querySelectorAll("li.upload-progress-item").length === 0) {
const fi = document.getElementById("file");
if (fi) fi.value = "";
pc.innerHTML = "";
adjustFolderHelpExpansionClosed();
const fileInfoContainer = document.getElementById("fileInfoContainer");
if (fileInfoContainer) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
const fic = document.getElementById("fileInfoContainer");
if (fic) {
fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
}
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault();
window.selectedFiles = [];
}
}, 5000);
}
@@ -850,7 +1102,7 @@ if (finishedCount === allFiles.length) {
const failed = allFiles.length - succeeded;
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
} else {
showToast(`${succeeded} file succeeded. Please check the list.`);
showToast(`${succeeded} file(s) succeeded. Please check the list.`);
}
})
.catch(error => {
@@ -859,7 +1111,6 @@ if (finishedCount === allFiles.length) {
})
.finally(() => {
loadFolderTree(window.currentFolder);
});
}
}
@@ -918,17 +1169,23 @@ function initUpload() {
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);
for (const f of files) {
resumableInstance.addFile(f);
}
} else {
// If still not ready (load error), fall back to your XHR path
// If Resumable failed to load, fall back to XHR
processFiles(files);
}
} else {
// Non-resumable: normal XHR path, drag-and-drop etc.
processFiles(files);
}
});
@@ -937,27 +1194,40 @@ function initUpload() {
if (uploadForm) {
uploadForm.addEventListener("submit", async function (e) {
e.preventDefault();
const files = window.selectedFiles || (fileInput ? fileInput.files : []);
const files =
(Array.isArray(window.selectedFiles) && window.selectedFiles.length)
? window.selectedFiles
: (fileInput ? Array.from(fileInput.files || []) : []);
if (!files || !files.length) {
showToast("No files selected.");
return;
}
// Resumable path (only for picked files, not folder uploads)
const first = files[0];
const isFolderish = !!(first.customRelativePath || first.webkitRelativePath);
if (useResumable && !isFolderish) {
// If we have any files queued in Resumable, treat this as a resumable upload.
const hasResumableFiles =
useResumable &&
resumableInstance &&
Array.isArray(resumableInstance.files) &&
resumableInstance.files.length > 0;
if (hasResumableFiles) {
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
// ensure folder/token fresh
// 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;
resumableInstance.upload();
showToast("Resumable upload started...");
} else {
// fallback
// Hard fallback should basically never happen
submitFiles(files);
}
} else {
// No resumable queue → drag-and-drop / paste / simple input → XHR path
submitFiles(files);
}
});
@@ -966,6 +1236,7 @@ function initUpload() {
if (useResumable) {
initResumableUpload();
}
showResumableDraftBanner();
}
export { initUpload };

View File

@@ -1,2 +1,2 @@
// generated by CI
window.APP_VERSION = 'v1.9.2';
window.APP_VERSION = 'v1.9.11';

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 KiB

View File

@@ -6,91 +6,530 @@ require_once PROJECT_ROOT . '/src/models/AdminModel.php';
class AdminController
{
public function getConfig(): void
{
header('Content-Type: application/json; charset=utf-8');
$config = AdminModel::getConfig();
if (isset($config['error'])) {
http_response_code(500);
header('Cache-Control: no-store');
echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
/** Enforce authentication (401). */
private static function requireAuth(): void
{
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Unauthorized']);
exit;
}
}
/** Enforce admin (401). */
private static function requireAdmin(): void
{
self::requireAuth();
// Prefer the session flag
$isAdmin = (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true);
// Fallback: check the users role in storage (e.g., users.txt/DB)
if (!$isAdmin) {
$u = $_SESSION['username'] ?? '';
if ($u) {
try {
// UserModel::getUserRole($u) should return '1' for admins
$isAdmin = (UserModel::getUserRole($u) === '1');
if ($isAdmin) {
// Normalize session so downstream ACL checks see admin
$_SESSION['isAdmin'] = true;
}
} catch (\Throwable $e) {
// ignore and continue to deny
}
}
}
if (!$isAdmin) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Admin privileges required.']);
exit;
}
}
/** Get headers in lowercase, robust across SAPIs. */
private static function headersLower(): array
{
$headers = function_exists('getallheaders') ? getallheaders() : [];
$out = [];
foreach ($headers as $k => $v) {
$out[strtolower($k)] = $v;
}
// Fallbacks from $_SERVER if needed
foreach ($_SERVER as $k => $v) {
if (strpos($k, 'HTTP_') === 0) {
$h = strtolower(str_replace('_', '-', substr($k, 5)));
if (!isset($out[$h])) $out[$h] = $v;
}
}
return $out;
}
/** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
private static function requireCsrf(): void
{
$h = self::headersLower();
$token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
if (empty($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid CSRF token']);
exit;
}
}
/** Read JSON body (empty array if not valid). */
private static function readJson(): array
{
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
public function getConfig(): void
{
header('Content-Type: application/json; charset=utf-8');
$config = AdminModel::getConfig();
if (isset($config['error'])) {
http_response_code(500);
header('Cache-Control: no-store');
echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
}
// ---- Effective ONLYOFFICE values (constants override adminConfig) ----
$ooCfg = is_array($config['onlyoffice'] ?? null) ? $config['onlyoffice'] : [];
$effEnabled = defined('ONLYOFFICE_ENABLED')
? (bool) ONLYOFFICE_ENABLED
: (bool) ($ooCfg['enabled'] ?? false);
$effDocs = (defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== '')
? (string) ONLYOFFICE_DOCS_ORIGIN
: (string) ($ooCfg['docsOrigin'] ?? '');
$hasSecret = defined('ONLYOFFICE_JWT_SECRET')
? (ONLYOFFICE_JWT_SECRET !== '')
: (!empty($ooCfg['jwtSecret']));
$publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? '');
// ---- Pro / license info (all guarded for clean core installs) ----
$licenseString = null;
if (defined('PRO_LICENSE_FILE') && PRO_LICENSE_FILE && @is_file(PRO_LICENSE_FILE)) {
$json = @file_get_contents(PRO_LICENSE_FILE);
if ($json !== false) {
$decoded = json_decode($json, true);
if (is_array($decoded) && !empty($decoded['license'])) {
$licenseString = (string) $decoded['license'];
}
}
}
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
// FR_PRO_INFO is only defined when bootstrap_pro.php has run; guard it
$proPayload = [];
if (defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)) {
$p = FR_PRO_INFO['payload'] ?? null;
if (is_array($p)) {
$proPayload = $p;
}
}
$proType = $proPayload['type'] ?? null;
$proEmail = $proPayload['email'] ?? null;
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
$public = [
'header_title' => (string)($config['header_title'] ?? 'FileRise'),
'loginOptions' => [
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
],
'globalOtpauthUrl' => (string)($config['globalOtpauthUrl'] ?? ''),
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
'oidc' => [
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
// never include clientId/clientSecret
],
'onlyoffice' => [
// Public only needs to know if its on; no secrets/origins here.
'enabled' => $effEnabled,
],
'branding' => [
'customLogoUrl' => (string)($config['branding']['customLogoUrl'] ?? ''),
'headerBgLight' => (string)($config['branding']['headerBgLight'] ?? ''),
'headerBgDark' => (string)($config['branding']['headerBgDark'] ?? ''),
],
'pro' => [
'active' => $proActive,
'type' => $proType,
'email' => $proEmail,
'version' => $proVersion,
'license' => $licenseString,
],
];
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
if ($isAdmin) {
// admin-only extras: presence flags + proxy options + ONLYOFFICE effective view
$adminExtra = [
'loginOptions' => array_merge($public['loginOptions'], [
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
]),
'oidc' => array_merge($public['oidc'], [
'hasClientId' => !empty($config['oidc']['clientId']),
'hasClientSecret' => !empty($config['oidc']['clientSecret']),
]),
'onlyoffice' => [
'enabled' => $effEnabled,
'docsOrigin' => $effDocs, // effective (constants win)
'publicOrigin' => $publicOriginCfg, // optional override from adminConfig
'hasJwtSecret' => (bool)$hasSecret, // boolean only; never leak secret
'lockedByPhp' => (
defined('ONLYOFFICE_ENABLED')
|| defined('ONLYOFFICE_DOCS_ORIGIN')
|| defined('ONLYOFFICE_JWT_SECRET')
|| defined('ONLYOFFICE_PUBLIC_ORIGIN')
),
],
];
header('Cache-Control: no-store'); // dont cache admin config
echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
}
// Non-admins / unauthenticated: only the public subset
header('Cache-Control: no-store');
echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
}
public function setLicense(): void
{
// Always respond JSON
header('Content-Type: application/json; charset=utf-8');
try {
// Same guards as other admin endpoints
self::requireAuth();
self::requireAdmin();
self::requireCsrf();
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '{}', true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
return;
}
// ---- Effective ONLYOFFICE values (constants override adminConfig) ----
$ooCfg = is_array($config['onlyoffice'] ?? null) ? $config['onlyoffice'] : [];
$effEnabled = defined('ONLYOFFICE_ENABLED')
? (bool) ONLYOFFICE_ENABLED
: (bool) ($ooCfg['enabled'] ?? false);
$license = isset($data['license']) ? trim((string)$data['license']) : '';
$effDocs = defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== ''
? (string) ONLYOFFICE_DOCS_ORIGIN
: (string) ($ooCfg['docsOrigin'] ?? '');
// 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');
}
$hasSecret = defined('ONLYOFFICE_JWT_SECRET')
? (ONLYOFFICE_JWT_SECRET !== '')
: (!empty($ooCfg['jwtSecret']));
$publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? '');
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
$public = [
'header_title' => (string)($config['header_title'] ?? 'FileRise'),
'loginOptions' => [
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
],
'globalOtpauthUrl' => (string)($config['globalOtpauthUrl'] ?? ''),
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
'oidc' => [
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
// never include clientId/clientSecret
],
'onlyoffice' => [
// Public only needs to know if its on; no secrets/origins here.
'enabled' => $effEnabled,
],
$payload = [
'license' => $license,
'updatedAt' => gmdate('c'),
];
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
if ($isAdmin) {
// admin-only extras: presence flags + proxy options + ONLYOFFICE effective view
$adminExtra = [
'loginOptions' => array_merge($public['loginOptions'], [
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
]),
'oidc' => array_merge($public['oidc'], [
'hasClientId' => !empty($config['oidc']['clientId']),
'hasClientSecret' => !empty($config['oidc']['clientSecret']),
]),
'onlyoffice' => [
'enabled' => $effEnabled,
'docsOrigin' => $effDocs, // effective (constants win)
'publicOrigin' => $publicOriginCfg, // optional override from adminConfig
'hasJwtSecret' => (bool)$hasSecret, // boolean only; never leak secret
'lockedByPhp' => (
defined('ONLYOFFICE_ENABLED')
|| defined('ONLYOFFICE_DOCS_ORIGIN')
|| defined('ONLYOFFICE_JWT_SECRET')
),
],
];
header('Cache-Control: no-store'); // dont cache admin config
echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$dir = dirname(PRO_LICENSE_FILE);
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to create license dir']);
return;
}
// Non-admins / unauthenticated: only the public subset
header('Cache-Control: no-store');
echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
$json = json_encode($payload, JSON_PRETTY_PRINT);
if ($json === false || file_put_contents(PRO_LICENSE_FILE, $json) === false) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to write license file']);
return;
}
echo json_encode(['success' => true]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Exception: ' . $e->getMessage(),
]);
}
}
public function installProBundle(): void
{
header('Content-Type: application/json; charset=utf-8');
try {
// Guard rails: method + auth + CSRF
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
return;
}
self::requireAuth();
self::requireAdmin();
self::requireCsrf();
// Ensure ZipArchive is available
if (!class_exists('\\ZipArchive')) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'ZipArchive extension is required on the server.']);
return;
}
// Basic upload validation
if (empty($_FILES['bundle']) || !is_array($_FILES['bundle'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing uploaded bundle (field "bundle").']);
return;
}
$f = $_FILES['bundle'];
if (!empty($f['error']) && $f['error'] !== UPLOAD_ERR_OK) {
$msg = 'Upload error.';
switch ($f['error']) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
$msg = 'Uploaded file exceeds size limit.';
break;
case UPLOAD_ERR_PARTIAL:
$msg = 'Uploaded file was only partially received.';
break;
case UPLOAD_ERR_NO_FILE:
$msg = 'No file was uploaded.';
break;
default:
$msg = 'Upload failed with error code ' . (int)$f['error'];
break;
}
http_response_code(400);
echo json_encode(['success' => false, 'error' => $msg]);
return;
}
$tmpName = $f['tmp_name'] ?? '';
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid uploaded file.']);
return;
}
// Guard against unexpectedly large bundles (e.g., >100MB)
$size = isset($f['size']) ? (int)$f['size'] : 0;
if ($size <= 0 || $size > 100 * 1024 * 1024) {
http_response_code(413);
echo json_encode(['success' => false, 'error' => 'Bundle size is invalid or too large (max 100MB).']);
return;
}
// Optional: require .zip extension by name (best-effort)
$origName = (string)($f['name'] ?? '');
if ($origName !== '' && !preg_match('/\.zip$/i', $origName)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Bundle must be a .zip file.']);
return;
}
// Prepare temp working dir
$tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
$workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
if (!@mkdir($workDir, 0700, true)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to prepare temp dir.']);
return;
}
$zipPath = $workDir . DIRECTORY_SEPARATOR . 'bundle.zip';
if (!@move_uploaded_file($tmpName, $zipPath)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to move uploaded bundle.']);
return;
}
$zip = new \ZipArchive();
if ($zip->open($zipPath) !== true) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Failed to open ZIP bundle.']);
return;
}
$installed = [
'src' => [],
'public' => [],
'docs' => [],
];
$projectRoot = rtrim(PROJECT_ROOT, DIRECTORY_SEPARATOR);
// Where Pro bundle code lives (defaults to PROJECT_ROOT . '/users/pro')
$bundleRoot = defined('FR_PRO_BUNDLE_DIR')
? rtrim(FR_PRO_BUNDLE_DIR, DIRECTORY_SEPARATOR)
: ($projectRoot . DIRECTORY_SEPARATOR . 'users' . DIRECTORY_SEPARATOR . 'pro');
// Put README-Pro.txt / LICENSE-Pro.txt inside the bundle dir as well
$proDocsDir = $bundleRoot;
if (!is_dir($proDocsDir)) {
@mkdir($proDocsDir, 0755, true);
}
$allowedTopLevel = ['LICENSE-Pro.txt', 'README-Pro.txt'];
// Iterate entries and selectively extract/copy expected files only
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false) {
continue;
}
// Normalise and guard
$name = ltrim($name, "/\\");
if ($name === '' || substr($name, -1) === '/') {
continue; // skip directories
}
if (strpos($name, '../') !== false || strpos($name, '..\\') !== false) {
continue; // path traversal guard
}
// Ignore macOS Finder junk: __MACOSX and "._" resource forks
$base = basename($name);
if (
str_starts_with($name, '__MACOSX/') ||
str_contains($name, '/__MACOSX/') ||
str_starts_with($base, '._')
) {
continue;
}
$targetPath = null;
$category = null;
if (in_array($name, $allowedTopLevel, true)) {
// Docs → bundle dir (under /users/pro)
$targetPath = $proDocsDir . DIRECTORY_SEPARATOR . $name;
$category = 'docs';
} elseif (strpos($name, 'src/pro/') === 0) {
// e.g. src/pro/bootstrap_pro.php -> FR_PRO_BUNDLE_DIR/bootstrap_pro.php
$relative = substr($name, strlen('src/pro/'));
if ($relative === '' || substr($relative, -1) === '/') {
continue;
}
$targetPath = $bundleRoot . DIRECTORY_SEPARATOR . $relative;
$category = 'src';
} 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;
}
if (!$targetPath || !$category) {
continue;
}
// Track whether we're overwriting an existing file (for reporting only)
$wasExisting = is_file($targetPath);
// Read from ZIP entry
$stream = $zip->getStream($name);
if (!$stream) {
continue;
}
$dir = dirname($targetPath);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
fclose($stream);
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to create destination directory for ' . $name]);
return;
}
$data = stream_get_contents($stream);
fclose($stream);
if ($data === false) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to read data for ' . $name]);
return;
}
// Always overwrite target file on install/upgrade
if (@file_put_contents($targetPath, $data) === false) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to write ' . $name]);
return;
}
@chmod($targetPath, 0644);
// Track what we installed (and whether it was overwritten)
if (!isset($installed[$category])) {
$installed[$category] = [];
}
$installed[$category][] = $targetPath . ($wasExisting ? ' (overwritten)' : '');
}
$zip->close();
// Best-effort cleanup; ignore failures
@unlink($zipPath);
@rmdir($workDir);
// Reflect current Pro status in response if bootstrap was loaded
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
$proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)
? (FR_PRO_INFO['payload'] ?? null)
: null;
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
echo json_encode([
'success' => true,
'message' => 'Pro bundle installed.',
'installed' => $installed,
'proActive' => (bool)$proActive,
'proVersion' => $proVersion,
'proPayload' => $proPayload,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Exception during bundle install: ' . $e->getMessage(),
]);
}
}
public function updateConfig(): void
{
@@ -149,6 +588,11 @@ class AdminController
'clientSecret'=> '',
'redirectUri' => ''
],
'branding' => [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
],
];
// header_title (cap length and strip control chars)
@@ -250,6 +694,7 @@ class AdminController
exit;
}
}
// —– persist merged config —–
// ---- ONLYOFFICE: merge from payload (unless locked by PHP defines) ----
@@ -286,6 +731,22 @@ class AdminController
$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];
}
}
}
$result = AdminModel::updateConfig($merged);
if (isset($result['error'])) {
http_response_code(500);

View File

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

View File

@@ -6,6 +6,7 @@ require_once PROJECT_ROOT . '/src/models/FolderModel.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/FolderMeta.php';
require_once PROJECT_ROOT . '/src/lib/FS.php';
class FolderController
{
@@ -31,6 +32,10 @@ class FolderController
return $headers;
}
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array {
return FolderModel::listChildren($folder, $user, $perms, $cursor, $limit);
}
/** Stats for a folder (currently: empty/non-empty via folders/files counts). */
public static function stats(string $folder, string $user, array $perms): array
{
@@ -38,6 +43,161 @@ class FolderController
return FolderModel::countVisible($folder, $user, $perms);
}
/** Capabilities for UI buttons/menus (unchanged semantics; just centralized). */
public static function capabilities(string $folder, string $username): array
{
$folder = ACL::normalizeFolder($folder);
$perms = self::loadPermsFor($username);
$isAdmin = ACL::isAdmin($perms);
$folderOnly = self::boolFrom($perms, 'folderOnly','userFolderOnly','UserFolderOnly');
$readOnly = !empty($perms['readOnly']);
$disableUpload = !empty($perms['disableUpload']);
$isOwner = ACL::isOwner($username, $perms, $folder);
$inScope = self::inUserFolderScope($folder, $username, $perms, $isAdmin, $folderOnly);
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
$canView = $canViewBase && $inScope;
$canUpload = $gUploadBase && !$readOnly && !$disableUpload && $inScope;
$canCreate = $gCreateBase && !$readOnly && $inScope;
$canRename = $gRenameBase && !$readOnly && $inScope;
$canDelete = $gDeleteBase && !$readOnly && $inScope;
$canDeleteFile = $gDeleteBase && !$readOnly && $inScope;
$canDeleteFolder = !$readOnly && $inScope && (
$isAdmin ||
$isOwner ||
ACL::canManage($username, $perms, $folder) ||
$gDeleteBase // if your ACL::canDelete should also allow folder deletes
);
$canReceive = ($gUploadBase || $gCreateBase || $isAdmin) && !$readOnly && !$disableUpload && $inScope;
$canMoveIn = $canReceive;
$canEdit = $gEditBase && !$readOnly && $inScope;
$canCopy = $gCopyBase && !$readOnly && $inScope;
$canExtract = $gExtractBase && !$readOnly && $inScope;
$canShareEff = $canShareBase && $inScope;
$canShareFile = $gShareFile && $inScope;
$canShareFold = $gShareFolder && $inScope;
$isRoot = ($folder === 'root');
$canMoveFolder = false;
if ($isRoot) {
$canRename = false;
$canDelete = false;
$canShareFold = false;
} else {
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
&& !$readOnly;
}
$owner = null;
try { if (class_exists('FolderModel') && method_exists('FolderModel','getOwnerFor')) $owner = FolderModel::getOwnerFor($folder); } catch (\Throwable $e) {}
return [
'user' => $username,
'folder' => $folder,
'isAdmin' => $isAdmin,
'flags' => [
'folderOnly' => $folderOnly,
'readOnly' => $readOnly,
'disableUpload' => $disableUpload,
],
'owner' => $owner,
'canView' => $canView,
'canViewOwn' => $canViewOwn,
'canUpload' => $canUpload,
'canCreate' => $canCreate,
'canRename' => $canRename,
'canDelete' => $canDeleteFile,
'canDeleteFolder' => $canDeleteFolder,
'canMoveIn' => $canMoveIn,
'canMove' => $canMoveIn, // legacy alias
'canMoveFolder' => $canMoveFolder,
'canEdit' => $canEdit,
'canCopy' => $canCopy,
'canExtract' => $canExtract,
'canShare' => $canShareEff, // legacy umbrella
'canShareFile' => $canShareFile,
'canShareFolder' => $canShareFold,
];
}
/* ---------------------------
Private helpers (caps)
----------------------------*/
private static function loadPermsFor(string $u): array {
try {
if (function_exists('loadUserPermissions')) {
$p = loadUserPermissions($u);
return is_array($p) ? $p : [];
}
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
$all = userModel::getUserPermissions();
if (is_array($all)) {
if (isset($all[$u])) return (array)$all[$u];
$lk = strtolower($u);
if (isset($all[$lk])) return (array)$all[$lk];
}
}
} catch (\Throwable $e) {}
return [];
}
private static function boolFrom(array $a, string ...$keys): bool {
foreach ($keys as $k) if (!empty($a[$k])) return true;
return false;
}
private static function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
$f = ACL::normalizeFolder($folder);
if (ACL::isOwner($user, $perms, $f)) return true;
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
$pos = strrpos($f, '/');
if ($pos === false) break;
$f = substr($f, 0, $pos);
if ($f === '' || strcasecmp($f, 'root') === 0) break;
if (ACL::isOwner($user, $perms, $f)) return true;
}
return false;
}
private static function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin, bool $folderOnly): bool {
if ($isAdmin) return true;
if (!$folderOnly) return true; // normal users: global scope
$f = ACL::normalizeFolder($folder);
if ($f === 'root' || $f === '') {
return self::isOwnerOrAncestorOwner($u, $perms, $f);
}
if ($f === $u || str_starts_with($f, $u . '/')) return true;
return self::isOwnerOrAncestorOwner($u, $perms, $f);
}
private static function requireCsrf(): void
{
self::ensureSession();
@@ -1123,8 +1283,11 @@ class FolderController
$map = FolderMeta::getMap();
$out = [];
foreach ($map as $folder => $hex) {
$folder = FolderMeta::normalizeFolder($folder);
if (ACL::canRead($user, $perms, $folder)) $out[$folder] = $hex;
$folder = FolderMeta::normalizeFolder((string)$folder);
if ($folder === 'root') continue; // dont bother exposing root
if (ACL::canRead($user, $perms, $folder) || ACL::canReadOwn($user, $perms, $folder)) {
$out[$folder] = $hex;
}
}
echo json_encode($out, JSON_UNESCAPED_SLASHES);
}
@@ -1153,21 +1316,29 @@ class FolderController
$body = json_decode(file_get_contents('php://input') ?: "{}", true) ?: [];
$folder = FolderMeta::normalizeFolder((string)($body['folder'] ?? 'root'));
$color = isset($body['color']) ? (string)$body['color'] : '';
$raw = array_key_exists('color', $body) ? (string)$body['color'] : '';
// Treat “customize color” as rename-level capability (your convention)
if (!ACL::canRename($user, $perms, $folder)) {
if ($folder === 'root') {
http_response_code(400);
echo json_encode(['error' => 'Cannot set color on root']);
return;
}
// >>> Require canEdit (not canRename) <<<
if (!ACL::canEdit($user, $perms, $folder) && !ACL::isAdmin($perms)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
return;
}
try {
$res = FolderMeta::setColor($folder, $color === '' ? null : $color);
// empty string clears; non-empty must be valid #RGB or #RRGGBB
$hex = ($raw === '') ? null : FolderMeta::normalizeHex($raw);
$res = FolderMeta::setColor($folder, $hex);
echo json_encode(['success' => true] + $res, JSON_UNESCAPED_SLASHES);
} catch (\InvalidArgumentException $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
echo json_encode(['error' => 'Invalid color']);
}
}

View File

@@ -44,9 +44,6 @@ class MediaController
$f = trim((string)$f);
return ($f==='' || strtolower($f)==='root') ? 'root' : $f;
}
private function validFolder($f): bool {
return $f==='root' || (bool)preg_match(REGEX_FOLDER_NAME, $f);
}
private function validFile($f): bool {
$f = basename((string)$f);
return $f !== '' && (bool)preg_match(REGEX_FILE_NAME, $f);
@@ -56,6 +53,24 @@ class MediaController
return ACL::canRead($username, $perms, $folder) ? null : "Forbidden";
}
private function validFolder($f): bool {
if ($f === 'root') return true;
// Validate per-segment against your REGEX_FOLDER_NAME
$parts = array_filter(explode('/', (string)$f), fn($p) => $p !== '');
if (!$parts) return false;
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) return false;
}
return true;
}
/** “View” means read OR read_own */
private function canViewFolder(string $folder, string $username): bool {
$perms = loadUserPermissions($username) ?: [];
return ACL::canRead($username, $perms, $folder)
|| ACL::canReadOwn($username, $perms, $folder);
}
/** POST /api/media/updateProgress.php */
public function updateProgress(): void {
$this->jsonStart();
@@ -67,15 +82,15 @@ class MediaController
$d = $this->readJson();
$folder = $this->normalizeFolder($d['folder'] ?? 'root');
$file = (string)($d['file'] ?? '');
$seconds = isset($d['seconds']) ? floatval($d['seconds']) : 0.0;
$duration = isset($d['duration']) ? floatval($d['duration']) : null;
$seconds = isset($d['seconds']) ? (float)$d['seconds'] : 0.0;
$duration = isset($d['duration']) ? (float)$d['duration'] : null;
$completed = isset($d['completed']) ? (bool)$d['completed'] : null;
$clear = isset($d['clear']) ? (bool)$d['clear'] : false;
$clear = !empty($d['clear']);
if (!$this->validFolder($folder) || !$this->validFile($file)) {
$this->out(['error'=>'Invalid folder/file'], 400); return;
}
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
if (!$this->canViewFolder($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
if ($clear) {
$ok = MediaModel::clearProgress($u, $folder, $file);
@@ -102,7 +117,7 @@ class MediaController
if (!$this->validFolder($folder) || !$this->validFile($file)) {
$this->out(['error'=>'Invalid folder/file'], 400); return;
}
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
if (!$this->canViewFolder($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
$row = MediaModel::getProgress($u, $folder, $file);
$this->out(['state'=>$row]);
@@ -123,7 +138,12 @@ class MediaController
if (!$this->validFolder($folder)) {
$this->out(['error'=>'Invalid folder'], 400); return;
}
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
// Soft-fail for restricted users: avoid noisy console 403s
if (!$this->canViewFolder($folder, $u)) {
$this->out(['map' => []]); // 200 OK, no leakage
return;
}
$map = MediaModel::getFolderMap($u, $folder);
$this->out(['map'=>$map]);

View File

@@ -5,116 +5,143 @@ require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
class UploadController {
public function handleUpload(): void {
class UploadController
{
public function handleUpload(): void
{
header('Content-Type: application/json');
// ---- 1) CSRF (header or form field) ----
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
$received = '';
if (!empty($headersArr['x-csrf-token'])) {
$received = trim($headersArr['x-csrf-token']);
} elseif (!empty($_POST['csrf_token'])) {
$received = trim($_POST['csrf_token']);
} elseif (!empty($_POST['upload_token'])) {
// legacy alias
$received = trim($_POST['upload_token']);
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$requestParams = ($method === 'GET') ? $_GET : $_POST;
// Detect Resumable.js chunk "test" requests (testChunks=true, default GET)
$isResumableTest =
($method === 'GET'
&& isset($requestParams['resumableChunkNumber'])
&& isset($requestParams['resumableIdentifier']));
// ---- 1) CSRF (skip for resumable GET tests Resumable only cares about HTTP status) ----
if (!$isResumableTest) {
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
$received = '';
if (!empty($headersArr['x-csrf-token'])) {
$received = trim($headersArr['x-csrf-token']);
} elseif (!empty($requestParams['csrf_token'])) {
$received = trim((string)$requestParams['csrf_token']);
} elseif (!empty($requestParams['upload_token'])) {
// legacy alias
$received = trim((string)$requestParams['upload_token']);
}
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
// Soft-fail so client can retry with refreshed token
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
http_response_code(200);
echo json_encode([
'csrf_expired' => true,
'csrf_token' => $_SESSION['csrf_token'],
]);
return;
}
}
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
// Soft-fail so client can retry with refreshed token
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
http_response_code(200);
echo json_encode([
'csrf_expired' => true,
'csrf_token' => $_SESSION['csrf_token']
]);
return;
}
// ---- 2) Auth + account-level flags ----
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
return;
}
$username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms);
// Admins should never be blocked by account-level "disableUpload"
if (!$isAdmin && !empty($userPerms['disableUpload'])) {
http_response_code(403);
echo json_encode(['error' => 'Upload disabled for this user.']);
return;
}
// ---- 3) Folder-level WRITE permission (ACL) ----
// Always require client to send the folder; fall back to GET if needed.
$folderParam = isset($_POST['folder'])
? (string)$_POST['folder']
: (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
// Prefer the unified param array, fall back to GET only if needed.
$folderParam = isset($requestParams['folder'])
? (string)$requestParams['folder']
: (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
// Decode %xx (e.g., "test%20folder") then normalize
$folderParam = rawurldecode($folderParam);
$targetFolder = ACL::normalizeFolder($folderParam);
// Decode %xx (e.g., "test%20folder") then normalize
$folderParam = rawurldecode($folderParam);
$targetFolder = ACL::normalizeFolder($folderParam);
// Admins bypass folder canWrite checks
$username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms);
// Admins bypass folder canWrite checks
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
http_response_code(403);
echo json_encode([
'error' => 'Forbidden: no write access to folder "' . $targetFolder . '".',
]);
return;
}
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
return;
// ---- 4) Delegate to model (force the sanitized folder) ----
$requestParams['folder'] = $targetFolder;
// Keep legacy behavior for anything still reading $_POST directly
$_POST['folder'] = $targetFolder;
$result = UploadModel::handleUpload($requestParams, $_FILES);
// ---- 5) Special handling for Resumable.js GET tests ----
// Resumable only inspects HTTP status:
// 200 => chunk exists (skip)
// 404/other => chunk missing (upload)
if ($isResumableTest && isset($result['status'])) {
if ($result['status'] === 'found') {
http_response_code(200);
} else {
http_response_code(202); // 202 Accepted = chunk not found
}
echo json_encode($result);
return;
}
// ---- 6) Normal response handling ----
if (isset($result['error'])) {
http_response_code(400);
echo json_encode($result);
return;
}
if (isset($result['status'])) {
echo json_encode($result);
return;
}
echo json_encode([
'success' => $result['success'] ?? 'File uploaded successfully',
'newFilename' => $result['newFilename'] ?? null,
]);
}
// ---- 4) Delegate to model (force the sanitized folder) ----
$_POST['folder'] = $targetFolder; // in case model reads superglobal
$post = $_POST;
$post['folder'] = $targetFolder;
public function removeChunks(): void
{
header('Content-Type: application/json');
$result = UploadModel::handleUpload($post, $_FILES);
$receivedToken = isset($_POST['csrf_token']) ? trim((string)$_POST['csrf_token']) : '';
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
return;
}
// ---- 5) Response (unchanged) ----
if (isset($result['error'])) {
http_response_code(400);
echo json_encode($result);
return;
if (!isset($_POST['folder'])) {
http_response_code(400);
echo json_encode(['error' => 'No folder specified']);
return;
}
$folderRaw = (string)$_POST['folder'];
$folder = ACL::normalizeFolder(rawurldecode($folderRaw));
echo json_encode(UploadModel::removeChunks($folder));
}
if (isset($result['status'])) {
echo json_encode($result);
return;
}
echo json_encode([
'success' => 'File uploaded successfully',
'newFilename' => $result['newFilename'] ?? null
]);
}
public function removeChunks(): void {
header('Content-Type: application/json');
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
return;
}
if (!isset($_POST['folder'])) {
http_response_code(400);
echo json_encode(['error' => 'No folder specified']);
return;
}
$folderRaw = (string)$_POST['folder'];
$folder = ACL::normalizeFolder(rawurldecode($folderRaw));
echo json_encode(UploadModel::removeChunks($folder));
}
}

View File

@@ -649,8 +649,16 @@ class UserController
exit;
}
// Assuming /uploads maps to UPLOAD_DIR publicly
$url = '/uploads/profile_pics/' . $filename;
$fsPath = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics/' . $filename;
// Remove the filesystem root (PROJECT_ROOT) so we get a web-relative path
$root = rtrim(PROJECT_ROOT, '/\\');
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
// Ensure it starts with /
if ($url === '' || $url[0] !== '/') {
$url = '/' . $url;
}
$result = UserModel::setProfilePicture($_SESSION['username'], $url);
if (!($result['success'] ?? false)) {
@@ -667,6 +675,76 @@ class UserController
exit;
}
/**
* Upload branding logo (Pro-only; admin, CSRF).
* Reuses the profile_pics directory but does NOT change the user's avatar.
*/
public function uploadBrandLogo()
{
self::jsonHeaders();
// Auth, admin & CSRF
self::requireAuth();
self::requireAdmin();
self::requireCsrf();
if (empty($_FILES['brand_logo']) || $_FILES['brand_logo']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
exit;
}
$file = $_FILES['brand_logo'];
// Validate MIME & size (same rules as uploadPicture)
$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) {
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];
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username'] ?? 'logo');
$filename = 'branding_' . $user . '_' . 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;
}
$fsPath = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics/' . $filename;
// Remove the filesystem root (PROJECT_ROOT) so we get a web-relative path
$root = rtrim(PROJECT_ROOT, '/\\');
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
// Ensure it starts with /
if ($url === '' || $url[0] !== '/') {
$url = '/' . $url;
}
echo json_encode(['success' => true, 'url' => $url]);
exit;
}
public function siteConfig(): void
{
header('Content-Type: application/json');

87
src/lib/FS.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
// src/lib/FS.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
final class FS
{
/** Hidden/system names to ignore entirely */
public static function IGNORE(): array {
return ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
}
/** App-specific names to skip from UI */
public static function SKIP(): array {
return ['trash','profile_pics'];
}
public static function isSafeSegment(string $name): bool {
if ($name === '.' || $name === '..') return false;
if (strpos($name, '/') !== false || strpos($name, '\\') !== false) return false;
if (strpos($name, "\0") !== false) return false;
if (preg_match('/[\x00-\x1F]/u', $name)) return false;
$len = mb_strlen($name);
return $len > 0 && $len <= 255;
}
/** realpath($p) and ensure it remains inside $base (defends symlink escape). */
public static function safeReal(string $baseReal, string $p): ?string {
$rp = realpath($p);
if ($rp === false) return null;
$base = rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$rp2 = rtrim($rp, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (strpos($rp2, $base) !== 0) return null;
return rtrim($rp, DIRECTORY_SEPARATOR);
}
/**
* Small bounded DFS to learn if an unreadable folder has any readable descendant (for “locked” rows).
* $maxDepth intentionally small to avoid expensive scans.
*/
public static function hasReadableDescendant(
string $baseReal,
string $absPath,
string $relPath,
string $user,
array $perms,
int $maxDepth = 2
): bool {
if ($maxDepth <= 0 || !is_dir($absPath)) return false;
$IGNORE = self::IGNORE();
$SKIP = self::SKIP();
$items = @scandir($absPath) ?: [];
foreach ($items as $child) {
if ($child === '.' || $child === '..') continue;
if ($child[0] === '.') continue;
if (in_array($child, $IGNORE, true)) continue;
if (!self::isSafeSegment($child)) continue;
$lower = strtolower($child);
if (in_array($lower, $SKIP, true)) continue;
$abs = $absPath . DIRECTORY_SEPARATOR . $child;
if (!@is_dir($abs)) continue;
// Resolve symlink safely
if (@is_link($abs)) {
$safe = self::safeReal($baseReal, $abs);
if ($safe === null || !is_dir($safe)) continue;
$abs = $safe;
}
$rel = ($relPath === 'root') ? $child : ($relPath . '/' . $child);
if (ACL::canRead($user, $perms, $rel) || ACL::canReadOwn($user, $perms, $rel)) {
return true;
}
if ($maxDepth > 1 && self::hasReadableDescendant($baseReal, $abs, $rel, $user, $perms, $maxDepth - 1)) {
return true;
}
}
return false;
}
}

View File

@@ -73,6 +73,27 @@ class AdminModel
return ($scheme === 'http' || $scheme === 'https') ? $url : '';
}
/** Allow logo URLs that are either site-relative (/uploads/…) or http(s). */
private static function sanitizeLogoUrl($url): string
{
$url = trim((string)$url);
if ($url === '') return '';
// 1) Site-relative like "/uploads/profile_pics/branding_foo.png"
if ($url[0] === '/') {
// Strip CRLF just in case
$url = preg_replace('~[\r\n]+~', '', $url);
// Dont allow sneaky schemes embedded in a relative path
if (strpos($url, '://') !== false) {
return '';
}
return $url;
}
// 2) Fallback to plain http(s) validation
return self::sanitizeHttpUrl($url);
}
public static function buildPublicSubset(array $config): array
{
$public = [
@@ -89,6 +110,17 @@ class AdminModel
'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'] ?? ''
),
],
];
// NEW: include ONLYOFFICE minimal public flag
@@ -226,6 +258,30 @@ $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'] = '';
}
}
// Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
@@ -267,6 +323,18 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
return ["success" => "Configuration updated successfully."];
}
private static function sanitizeColorHex($value): string
{
$value = trim((string)$value);
if ($value === '') return '';
// allow #RGB or #RRGGBB
if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value)) {
return strtoupper($value);
}
return '';
}
/**
* Retrieves the current configuration.
*
@@ -368,6 +436,25 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
$config['onlyoffice']['publicOrigin'] = self::sanitizeHttpUrl($config['onlyoffice']['publicOrigin'] ?? '');
}
// Branding
if (!isset($config['branding']) || !is_array($config['branding'])) {
$config['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
];
} else {
$config['branding']['customLogoUrl'] = self::sanitizeLogoUrl(
$config['branding']['customLogoUrl'] ?? ''
);
$config['branding']['headerBgLight'] = self::sanitizeColorHex(
$config['branding']['headerBgLight'] ?? ''
);
$config['branding']['headerBgDark'] = self::sanitizeColorHex(
$config['branding']['headerBgDark'] ?? ''
);
}
return $config;
}
@@ -393,6 +480,11 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
'docsOrigin' => '',
'publicOrigin' => '',
],
'branding' => [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
],
];
}
}

View File

@@ -3,6 +3,7 @@
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/lib/FS.php';
class FolderModel
{
@@ -10,44 +11,228 @@ class FolderModel
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
* ============================================================ */
public static function countVisible(string $folder, string $user, array $perms): array
{
// Normalize
$folder = ACL::normalizeFolder($folder);
// ACL gate: if you cant read, report empty (no leaks)
if (!$user || !ACL::canRead($user, $perms, $folder)) {
return ['folders' => 0, 'files' => 0];
}
// Resolve paths under UPLOAD_DIR
$root = rtrim((string)UPLOAD_DIR, '/\\');
$path = ($folder === 'root') ? $root : ($root . '/' . $folder);
$realRoot = @realpath($root);
$realPath = @realpath($path);
if ($realRoot === false || $realPath === false || strpos($realPath, $realRoot) !== 0) {
return ['folders' => 0, 'files' => 0];
}
// Count quickly, skipping UI-internal dirs
$folders = 0; $files = 0;
try {
foreach (new DirectoryIterator($realPath) as $f) {
if ($f->isDot()) continue;
$name = $f->getFilename();
if ($name === 'trash' || $name === 'profile_pics') continue;
if ($f->isDir()) $folders++; else $files++;
if ($folders > 0 || $files > 0) break; // short-circuit: we only care if empty vs not
}
} catch (\Throwable $e) {
// Stay quiet + safe
$folders = 0; $files = 0;
}
return ['folders' => $folders, 'files' => $files];
}
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,
];
}
/* Helpers (private) */
private static function isSafeSegment(string $name): bool
{
if ($name === '.' || $name === '..') return false;
if (strpos($name, '/') !== false || strpos($name, '\\') !== false) return false;
if (strpos($name, "\0") !== false) return false;
if (preg_match('/[\x00-\x1F]/u', $name)) return false;
$len = mb_strlen($name);
return $len > 0 && $len <= 255;
}
private static function safeReal(string $baseReal, string $p): ?string
{
$rp = realpath($p);
if ($rp === false) return null;
$base = rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$rp2 = rtrim($rp, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (strpos($rp2, $base) !== 0) return null;
return rtrim($rp, DIRECTORY_SEPARATOR);
}
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array
{
$folder = ACL::normalizeFolder($folder);
$limit = max(1, min(2000, $limit));
$cursor = ($cursor !== null && $cursor !== '') ? $cursor : null;
$baseReal = realpath((string)UPLOAD_DIR);
if ($baseReal === false) return ['items' => [], 'nextCursor' => null];
// Resolve target directory
if ($folder === 'root') {
$dirReal = $baseReal;
$relPrefix = 'root';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!FS::isSafeSegment($seg)) return ['items'=>[], 'nextCursor'=>null];
}
$relPrefix = implode('/', $parts);
$dirGuess = $baseReal . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dirReal = FS::safeReal($baseReal, $dirGuess);
if ($dirReal === null || !is_dir($dirReal)) return ['items'=>[], 'nextCursor'=>null];
}
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP(); // lowercased names to skip (e.g. 'trash', 'profile_pics')
$entries = @scandir($dirReal);
if ($entries === false) return ['items'=>[], 'nextCursor'=>null];
$rows = []; // each: ['name'=>..., 'locked'=>bool, 'hasSubfolders'=>bool?, 'nonEmpty'=>bool?]
foreach ($entries as $item) {
if ($item === '.' || $item === '..') continue;
if ($item[0] === '.') continue;
if (in_array($item, $IGNORE, true)) continue;
if (!FS::isSafeSegment($item)) continue;
$lower = strtolower($item);
if (in_array($lower, $SKIP, true)) continue;
$full = $dirReal . DIRECTORY_SEPARATOR . $item;
if (!@is_dir($full)) continue;
// Symlink defense
if (@is_link($full)) {
$safe = FS::safeReal($baseReal, $full);
if ($safe === null || !is_dir($safe)) continue;
$full = $safe;
}
// ACL-relative path (for checks)
$rel = ($relPrefix === 'root') ? $item : $relPrefix . '/' . $item;
$canView = ACL::canRead($user, $perms, $rel) || ACL::canReadOwn($user, $perms, $rel);
$locked = !$canView;
// ---- quick per-child stats (single-level scan, early exit) ----
$hasSubs = false; // at least one subdirectory
$nonEmpty = false; // any direct entry (file or folder)
try {
$it = new \FilesystemIterator($full, \FilesystemIterator::SKIP_DOTS);
foreach ($it as $child) {
$name = $child->getFilename();
if (!$name) continue;
if ($name[0] === '.') continue;
if (!FS::isSafeSegment($name)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
$nonEmpty = true;
$isDir = $child->isDir();
if (!$isDir && $child->isLink()) {
$linkReal = FS::safeReal($baseReal, $child->getPathname());
$isDir = ($linkReal !== null && is_dir($linkReal));
}
if ($isDir) { $hasSubs = true; break; } // early exit once we know there's a subfolder
}
} catch (\Throwable $e) {
// keep defaults
}
// ---------------------------------------------------------------
if ($locked) {
// Show a locked row ONLY when this folder has a readable descendant
if (FS::hasReadableDescendant($baseReal, $full, $rel, $user, $perms, 2)) {
$rows[] = [
'name' => $item,
'locked' => true,
'hasSubfolders' => $hasSubs, // fine to keep structural chevrons
// nonEmpty intentionally omitted for locked nodes
];
}
} else {
$rows[] = [
'name' => $item,
'locked' => false,
'hasSubfolders' => $hasSubs,
'nonEmpty' => $nonEmpty,
];
}
}
// natural order + cursor pagination
usort($rows, fn($a, $b) => strnatcasecmp($a['name'], $b['name']));
$start = 0;
if ($cursor !== null) {
$n = count($rows);
for ($i = 0; $i < $n; $i++) {
if (strnatcasecmp($rows[$i]['name'], $cursor) > 0) { $start = $i; break; }
$start = $i + 1;
}
}
$page = array_slice($rows, $start, $limit);
$nextCursor = null;
if ($start + count($page) < count($rows)) {
$last = $page[count($page)-1];
$nextCursor = $last['name'];
}
return ['items' => $page, 'nextCursor' => $nextCursor];
}
/** Load the folder → owner map. */
public static function getFolderOwners(): array
@@ -213,40 +398,42 @@ class FolderModel
// -------- Normalize incoming values (use ONLY the parameters) --------
$folderName = trim((string)$folderName);
$parentIn = trim((string)$parent);
// If the client sent a path in folderName (e.g., "bob/new-sub") and parent is root/empty,
// derive parent = "bob" and folderName = "new-sub" so permission checks hit "bob".
$normalized = ACL::normalizeFolder($folderName);
if ($normalized !== 'root' && strpos($normalized, '/') !== false &&
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)) {
if (
$normalized !== 'root' && strpos($normalized, '/') !== false &&
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)
) {
$parentIn = trim(str_replace('\\', '/', dirname($normalized)), '/');
$folderName = basename($normalized);
if ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) $parentIn = 'root';
}
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
$folderName = trim($folderName);
if ($folderName === '') return ['success'=>false, 'error' => 'Folder name required'];
if ($folderName === '') return ['success' => false, 'error' => 'Folder name required'];
// ACL key for new folder
$newKey = ($parent === 'root') ? $folderName : ($parent . '/' . $folderName);
// -------- Compose filesystem paths --------
$base = rtrim((string)UPLOAD_DIR, "/\\");
$parentRel = ($parent === 'root') ? '' : str_replace('/', DIRECTORY_SEPARATOR, $parent);
$parentAbs = $parentRel ? ($base . DIRECTORY_SEPARATOR . $parentRel) : $base;
$newAbs = $parentAbs . DIRECTORY_SEPARATOR . $folderName;
// -------- Exists / sanity checks --------
if (!is_dir($parentAbs)) return ['success'=>false, 'error' => 'Parent folder does not exist'];
if (is_dir($newAbs)) return ['success'=>false, 'error' => 'Folder already exists'];
if (!is_dir($parentAbs)) return ['success' => false, 'error' => 'Parent folder does not exist'];
if (is_dir($newAbs)) return ['success' => false, 'error' => 'Folder already exists'];
// -------- Create directory --------
if (!@mkdir($newAbs, 0775, true)) {
$err = error_get_last();
return ['success'=>false, 'error' => 'Failed to create folder' . (!empty($err['message']) ? (': '.$err['message']) : '')];
return ['success' => false, 'error' => 'Failed to create folder' . (!empty($err['message']) ? (': ' . $err['message']) : '')];
}
// -------- Seed ACL --------
$inherit = defined('ACL_INHERIT_ON_CREATE') && ACL_INHERIT_ON_CREATE;
try {
@@ -265,9 +452,9 @@ class FolderModel
} catch (Throwable $e) {
// Roll back FS if ACL seeding fails
@rmdir($newAbs);
return ['success'=>false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
return ['success' => false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
}
return ['success' => true, 'folder' => $newKey];
}
@@ -318,7 +505,7 @@ class FolderModel
// Validate names (per-segment)
foreach ([$oldFolder, $newFolder] as $f) {
$parts = array_filter(explode('/', $f), fn($p)=>$p!=='');
$parts = array_filter(explode('/', $f), fn($p) => $p !== '');
if (empty($parts)) return ["error" => "Invalid folder name(s)."];
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
@@ -333,7 +520,7 @@ class FolderModel
$base = realpath(UPLOAD_DIR);
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p!=='');
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p !== '');
$newRel = implode('/', $newParts);
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
@@ -508,7 +695,7 @@ class FolderModel
return [
"record" => $record,
"folder" => $relative,
"realFolderPath"=> $realFolderPath,
"realFolderPath" => $realFolderPath,
"files" => $filesOnPage,
"currentPage" => $currentPage,
"totalPages" => $totalPages
@@ -532,7 +719,7 @@ class FolderModel
}
$expires = time() + max(1, $expirationSeconds);
$hashedPassword= $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
$hashedPassword = $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
$shareFile = META_DIR . "share_folder_links.json";
$links = file_exists($shareFile)
@@ -560,7 +747,7 @@ class FolderModel
// Build URL
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
$scheme = $https ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
$baseUrl = $scheme . '://' . rtrim($host, '/');
@@ -587,7 +774,7 @@ class FolderModel
return ["error" => "This share link has expired."];
}
[$realFolderPath, , $err] = self::resolveFolderPath((string)$record['folder'], false);
[$realFolderPath,, $err] = self::resolveFolderPath((string)$record['folder'], false);
if ($err || !is_dir($realFolderPath)) {
return ["error" => "Shared folder not found."];
}
@@ -615,8 +802,26 @@ class FolderModel
// Max size & allowed extensions (mirror FileModels common types)
$maxSize = 50 * 1024 * 1024; // 50 MB
$allowedExtensions = [
'jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx',
'mp4','webm','mp3','mkv','csv','json','xml','md'
'jpg',
'jpeg',
'png',
'gif',
'pdf',
'doc',
'docx',
'txt',
'xls',
'xlsx',
'ppt',
'pptx',
'mp4',
'webm',
'mp3',
'mkv',
'csv',
'json',
'xml',
'md'
];
$shareFile = META_DIR . "share_folder_links.json";
@@ -655,7 +860,7 @@ class FolderModel
// New safe filename
$safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
$newFilename= uniqid('', true) . "_" . $safeBase;
$newFilename = uniqid('', true) . "_" . $safeBase;
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
@@ -697,4 +902,4 @@ class FolderModel
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX);
return true;
}
}
}

View File

@@ -3,14 +3,17 @@
require_once PROJECT_ROOT . '/config/config.php';
class UploadModel {
private static function sanitizeFolder(string $folder): string {
class UploadModel
{
private static function sanitizeFolder(string $folder): string
{
// decode "%20", normalise slashes & trim via ACL helper
$f = ACL::normalizeFolder(rawurldecode($folder));
// model uses '' to represent root
if ($f === 'root') return '';
if ($f === 'root') {
return '';
}
// forbid dot segments / empty parts
foreach (explode('/', $f) as $seg) {
@@ -28,9 +31,13 @@ class UploadModel {
return $f; // safe, normalised, with spaces allowed
}
public static function handleUpload(array $post, array $files): array {
// --- GET resumable test (make folder handling consistent)
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($post['resumableTest'])) {
public static function handleUpload(array $post, array $files): array
{
// --- GET resumable test (make folder handling consistent) ---
if (
(($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET')
&& isset($post['resumableChunkNumber'], $post['resumableIdentifier'])
) {
$chunkNumber = (int)($post['resumableChunkNumber'] ?? 0);
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
@@ -38,15 +45,16 @@ class UploadModel {
$baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
$chunkFile = $tempDir . $chunkNumber;
return ["status" => file_exists($chunkFile) ? "found" : "not found"];
return ['status' => file_exists($chunkFile) ? 'found' : 'not found'];
}
// --- CHUNKED ---
// --- CHUNKED (Resumable.js POST uploads) ---
if (isset($post['resumableChunkNumber'])) {
$chunkNumber = (int)$post['resumableChunkNumber'];
$totalChunks = (int)$post['resumableTotalChunks'];
@@ -54,109 +62,126 @@ class UploadModel {
$resumableFilename = urldecode(basename($post['resumableFilename'] ?? ''));
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
return ["error" => "Invalid file name: $resumableFilename"];
return ['error' => "Invalid file name: $resumableFilename"];
}
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
if (empty($files['file']) || !isset($files['file']['name'])) {
return ["error" => "No files received"];
return ['error' => 'No files received'];
}
$baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
return ["error" => "Failed to create upload directory"];
return ['error' => 'Failed to create upload directory'];
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir) && !mkdir($tempDir, 0775, true)) {
return ["error" => "Failed to create temporary chunk directory"];
return ['error' => 'Failed to create temporary chunk directory'];
}
$chunkErr = $files['file']['error'] ?? UPLOAD_ERR_NO_FILE;
if ($chunkErr !== UPLOAD_ERR_OK) {
return ["error" => "Upload error on chunk $chunkNumber"];
return ['error' => "Upload error on chunk $chunkNumber"];
}
$chunkFile = $tempDir . $chunkNumber;
$tmpName = $files['file']['tmp_name'] ?? null;
if (!$tmpName || !move_uploaded_file($tmpName, $chunkFile)) {
return ["error" => "Failed to move uploaded chunk $chunkNumber"];
return ['error' => "Failed to move uploaded chunk $chunkNumber"];
}
// all chunks present?
// All chunks present?
for ($i = 1; $i <= $totalChunks; $i++) {
if (!file_exists($tempDir . $i)) {
return ["status" => "chunk uploaded"];
return ['status' => 'chunk uploaded'];
}
}
// merge
// Merge
$targetPath = $baseUploadDir . $resumableFilename;
if (!$out = fopen($targetPath, "wb")) {
return ["error" => "Failed to open target file for writing"];
if (!$out = fopen($targetPath, 'wb')) {
return ['error' => 'Failed to open target file for writing'];
}
for ($i = 1; $i <= $totalChunks; $i++) {
$chunkPath = $tempDir . $i;
if (!file_exists($chunkPath)) { fclose($out); return ["error" => "Chunk $i missing during merge"]; }
if (!$in = fopen($chunkPath, "rb")) { fclose($out); return ["error" => "Failed to open chunk $i"]; }
while ($buff = fread($in, 4096)) { fwrite($out, $buff); }
if (!file_exists($chunkPath)) {
fclose($out);
return ['error' => "Chunk $i missing during merge"];
}
if (!$in = fopen($chunkPath, 'rb')) {
fclose($out);
return ['error' => "Failed to open chunk $i"];
}
while ($buff = fread($in, 4096)) {
fwrite($out, $buff);
}
fclose($in);
}
fclose($out);
// metadata
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
// Metadata
$metadataKey = ($folderSan === '') ? 'root' : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
$collection = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_array($collection)) $collection = [];
$uploader = $_SESSION['username'] ?? 'Unknown';
$collection = file_exists($metadataFile)
? json_decode(file_get_contents($metadataFile), true)
: [];
if (!is_array($collection)) {
$collection = [];
}
if (!isset($collection[$resumableFilename])) {
$collection[$resumableFilename] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
$collection[$resumableFilename] = [
'uploaded' => $uploadedDate,
'uploader' => $uploader,
];
file_put_contents($metadataFile, json_encode($collection, JSON_PRETTY_PRINT));
}
// cleanup temp
// Cleanup temp
self::rrmdir($tempDir);
return ["success" => "File uploaded successfully"];
return ['success' => 'File uploaded successfully'];
}
// --- NON-CHUNKED ---
// --- NON-CHUNKED (drag-and-drop / folder uploads) ---
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
$baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
return ["error" => "Failed to create upload directory"];
return ['error' => 'Failed to create upload directory'];
}
$safeFileNamePattern = REGEX_FILE_NAME;
$metadataCollection = [];
$metadataChanged = [];
foreach ($files["file"]["name"] as $index => $fileName) {
foreach ($files['file']['name'] as $index => $fileName) {
if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
return ["error" => "Error uploading file"];
return ['error' => 'Error uploading file'];
}
$safeFileName = trim(urldecode(basename($fileName)));
if (!preg_match($safeFileNamePattern, $safeFileName)) {
return ["error" => "Invalid file name: " . $fileName];
return ['error' => 'Invalid file name: ' . $fileName];
}
$relativePath = '';
if (isset($post['relativePath'])) {
$relativePath = is_array($post['relativePath']) ? ($post['relativePath'][$index] ?? '') : $post['relativePath'];
$relativePath = is_array($post['relativePath'])
? ($post['relativePath'][$index] ?? '')
: $post['relativePath'];
}
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
@@ -164,34 +189,41 @@ class UploadModel {
$subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') {
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
. str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
}
$safeFileName = basename($relativePath);
}
if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) {
return ["error" => "Failed to create subfolder: " . $uploadDir];
return ['error' => 'Failed to create subfolder: ' . $uploadDir];
}
$targetPath = $uploadDir . $safeFileName;
if (!move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
return ["error" => "Error uploading file"];
if (!move_uploaded_file($files['file']['tmp_name'][$index], $targetPath)) {
return ['error' => 'Error uploading file'];
}
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
$metadataKey = ($folderSan === '') ? 'root' : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
if (!isset($metadataCollection[$metadataKey])) {
$metadataCollection[$metadataKey] = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_array($metadataCollection[$metadataKey])) $metadataCollection[$metadataKey] = [];
$metadataCollection[$metadataKey] = file_exists($metadataFile)
? json_decode(file_get_contents($metadataFile), true)
: [];
if (!is_array($metadataCollection[$metadataKey])) {
$metadataCollection[$metadataKey] = [];
}
$metadataChanged[$metadataKey] = false;
}
if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
$metadataCollection[$metadataKey][$safeFileName] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
$uploader = $_SESSION['username'] ?? 'Unknown';
$metadataCollection[$metadataKey][$safeFileName] = [
'uploaded' => $uploadedDate,
'uploader' => $uploader,
];
$metadataChanged[$metadataKey] = true;
}
}
@@ -204,17 +236,17 @@ class UploadModel {
}
}
return ["success" => "Files uploaded successfully"];
return ['success' => 'Files uploaded successfully'];
}
/**
/**
* Recursively removes a directory and its contents.
*
* @param string $dir The directory to remove.
* @return void
*/
private static function rrmdir(string $dir): void {
private static function rrmdir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
@@ -231,7 +263,7 @@ class UploadModel {
}
rmdir($dir);
}
/**
* Removes the temporary chunk directory for resumable uploads.
*
@@ -240,25 +272,26 @@ class UploadModel {
* @param string $folder The folder name provided (URL-decoded).
* @return array Returns a status array indicating success or error.
*/
public static function removeChunks(string $folder): array {
public static function removeChunks(string $folder): array
{
$folder = urldecode($folder);
// The folder name should exactly match the "resumable_" pattern.
$regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u";
if (!preg_match($regex, $folder)) {
return ["error" => "Invalid folder name"];
return ['error' => 'Invalid folder name'];
}
$tempDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
if (!is_dir($tempDir)) {
return ["success" => true, "message" => "Temporary folder already removed."];
return ['success' => true, 'message' => 'Temporary folder already removed.'];
}
self::rrmdir($tempDir);
if (!is_dir($tempDir)) {
return ["success" => true, "message" => "Temporary folder removed."];
} else {
return ["error" => "Failed to remove temporary folder."];
return ['success' => true, 'message' => 'Temporary folder removed.'];
}
return ['error' => 'Failed to remove temporary folder.'];
}
}

View File

@@ -72,6 +72,23 @@ 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