Compare commits

...

16 Commits

Author SHA1 Message Date
Ryan
6758b5f73d release(v1.8.3): feat(mobile+ci): harden Capacitor switcher & make release-on-version robust 2025-11-04 20:58:34 -05:00
github-actions[bot]
30a0aaf05e chore(release): set APP_VERSION to v1.8.2 [skip ci] 2025-11-05 01:34:51 +00:00
Ryan
c843f00738 release(v1.8.2): media progress tracking + watched badges; PWA scaffolding; mobile switcher (closes #37) 2025-11-04 20:34:42 -05:00
github-actions[bot]
4bb9d81370 chore(release): set APP_VERSION to v1.8.1 [skip ci] 2025-11-03 21:59:58 +00:00
Ryan
29e0497730 release(v1.8.1): fix(security,onlyoffice): sanitize DS origin; safe api.js/iframe probes; better UX placeholder 2025-11-03 16:59:47 -05:00
github-actions[bot]
dd3a7a5145 chore(release): set APP_VERSION to v1.8.0 [skip ci] 2025-11-03 21:40:02 +00:00
Ryan
d00db803c3 release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers
Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately.
2025-11-03 16:39:48 -05:00
github-actions[bot]
77a94ecd85 chore(release): set APP_VERSION to v1.7.5 [skip ci] 2025-11-02 04:56:35 +00:00
Ryan
699873848e release(v1.7.5): retrigger CI bump ensure up to date 2025-11-02 00:56:24 -04:00
Ryan
9cb12c11a6 release(v1.7.5): retrigger CI bump (no code changes) 2025-11-02 00:49:36 -04:00
Ryan
c08876380b release(v1.7.5): retrigger CI bump; chore(ci): update bump workflow 2025-11-02 00:44:29 -04:00
Ryan
5b824888cb release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50) 2025-11-02 00:32:04 -04:00
Ryan
b7d7f7c3ce release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50) 2025-11-02 00:32:03 -04:00
github-actions[bot]
e509b7ac9c chore(release): set APP_VERSION to v1.7.4 [skip ci] 2025-10-31 22:18:01 +00:00
Ryan
947255d94c release(v1.7.4): login hint replace toast + fix unauth boot 2025-10-31 18:17:52 -04:00
Ryan
55d44ef880 release(1.7.4): login hint replaced toast + fix unauth boot 2025-10-31 18:11:08 -04:00
39 changed files with 3243 additions and 750 deletions

View File

@@ -17,26 +17,39 @@ jobs:
release:
runs-on: ubuntu-latest
concurrency:
group: release-${{ github.ref }}-${{ github.sha }}
cancel-in-progress: false
# Cancel older runs for the same branch/ref so only the latest proceeds
group: release-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout
- name: Checkout correct ref
uses: actions/checkout@v4
with:
fetch-depth: 0
# For workflow_run, use the triggering workflow's head_sha; else use the current SHA
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
- name: Ensure tags available
run: |
git fetch --tags --force --prune --quiet
- name: Show recent tags (debug)
run: git tag --list "v*" --sort=-v:refname | head -n 20
- name: Read version from version.js
id: ver
shell: bash
run: |
set -euo pipefail
VER=$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")
if [[ -z "$VER" ]]; then
echo "version.js at commit: $(git rev-parse --short HEAD)"
sed -n '1,80p' public/js/version.js || true
VER=$(
grep -Eo "APP_VERSION[^\\n]*['\"]v[0-9][^'\"]+['\"]" public/js/version.js \
| sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/" \
| tail -n1
)
if [[ -z "${VER:-}" ]]; then
echo "Could not parse APP_VERSION from version.js" >&2
exit 1
fi
@@ -69,7 +82,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}" # e.g. v1.6.12
VER="${{ steps.ver.outputs.version }}" # e.g. v1.8.2
ZIP="FileRise-${VER}.zip"
# Clean staging copy (exclude dotfiles you dont want)
@@ -195,7 +208,8 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.version }}
target_commitish: ${{ github.sha }}
# Point the tag at the same commit we checked out (handles workflow_run case)
target_commitish: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
name: ${{ steps.ver.outputs.version }}
body_path: RELEASE_BODY.md
generate_release_notes: false

View File

@@ -5,18 +5,25 @@ on:
push:
paths:
- "CHANGELOG.md"
workflow_dispatch: {}
permissions:
contents: write
concurrency:
group: bump-and-sync-${{ github.ref }}
cancel-in-progress: false
jobs:
bump_and_sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout FileRise
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Extract version from commit message
id: ver
@@ -32,6 +39,23 @@ jobs:
echo "No release(vX.Y.Z) tag in commit message; skipping bump."
fi
# Ensure we're on the branch and up to date BEFORE modifying files
- name: Ensure clean branch (no local mods), update from remote
if: steps.ver.outputs.version != ''
shell: bash
run: |
set -euo pipefail
# Be on a named branch that tracks the remote
git checkout -B "${{ github.ref_name }}" --track "origin/${{ github.ref_name }}" || git checkout -B "${{ github.ref_name }}"
# Make sure the worktree is clean
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "::error::Working tree not clean before update. Aborting."
git status --porcelain
exit 1
fi
# Update branch
git pull --rebase origin "${{ github.ref_name }}"
- name: Update public/js/version.js (source of truth)
if: steps.ver.outputs.version != ''
shell: bash
@@ -42,8 +66,6 @@ jobs:
window.APP_VERSION = '${{ steps.ver.outputs.version }}';
EOF
# ✂️ REMOVED: repo stamping of HTML/CSS/JS
- name: Commit version.js only
if: steps.ver.outputs.version != ''
shell: bash
@@ -56,7 +78,7 @@ jobs:
echo "No changes to commit"
else
git commit -m "chore(release): set APP_VERSION to ${{ steps.ver.outputs.version }} [skip ci]"
git push
git push origin "${{ github.ref_name }}"
fi
- name: Checkout filerise-docker
@@ -66,6 +88,7 @@ jobs:
repository: error311/filerise-docker
token: ${{ secrets.PAT_TOKEN }}
path: docker-repo
fetch-depth: 0
- name: Copy CHANGELOG.md and write VERSION
if: steps.ver.outputs.version != ''

View File

@@ -1,5 +1,159 @@
# Changelog
## Changees 11/4/2025 (v1.8.3)
release(v1.8.3): feat(mobile+ci): harden Capacitor switcher & make release-on-version robust
- switcher.js: allow running inside Capacitor; remove innerHTML usage; build nodes safely; normalize/strip creds from URLs; add withParam() for ?frapp=1; drop inline handlers; clamp rename length; minor UX polish.
- CI: cancel superseded runs per ref; checkout triggering commit (workflow_run head_sha); improve APP_VERSION parsing; point tag to checked-out commit; add recent-tag debug.
---
## Changes 11/4/2025 (v1.8.2)
release(v1.8.2): media progress tracking + watched badges; PWA scaffolding; mobile switcher (closes #37)
- **Highlights**
- Video: auto-save playback progress and mark “Watched”, with resume-on-open and inline status chips on list/gallery.
- Mobile: introduced FileRise Mobile (Capacitor) companion repo + in-app server switcher and PWA bits.
- **Details**
- API (new):
- POST /api/media/updateProgress.php — persist per-user progress (seconds/duration/completed).
- GET /api/media/getProgress.php — fetch per-file progress.
- GET /api/media/getViewedMap.php — folder map for badges.
- **Frontend (media):**
- Video previews now resume from last position, periodically save progress, and mark completed on end, with toasts.
- Added status badges (“Watched” / %-complete) in table & gallery; CSS polish for badges.
- Badges render during list/gallery refresh; safer filename wrapping for badge injection.
- **Mobile & PWA:**
- New in-app server switcher (Capacitor-aware) loaded only in app/standalone contexts.
- Service Worker + manifest added (root scope via /public/sw.js; worker body in /js/pwa/sw.js; manifest icons).
- main.js conditionally imports the mobile switcher and registers the SW on web origins only.
- **Notes**
- Companion repo: **filerise-mobile** (Capacitor app shell) created for iOS/Android distribution.
- No breaking changes expected; endpoints are additive.
Closes #37.
---
## Changes 11/3/2025 (V1.8.1)
release(v1.8.1): fix(security,onlyoffice): sanitize DS origin; safe api.js/iframe probes; better UX placeholder
- Add ONLYOFFICE URL sanitizers:
- getTrustedDocsOrigin(): enforce http/https, strip creds, normalize to origin
- buildOnlyOfficeApiUrl(): construct fixed /web-apps/.../api.js via URL()
- Probe hardening (addresses CodeQL js/xss-through-dom):
- ooProbeScript/ooProbeFrame now use sanitized origins and fixed paths
- optional CSP nonce support for injected script
- optional iframe sandbox; robust cleanup/timeout handling
- CSP helper now renders lines based on validated origin (fallback to raw for visibility)
- Admin UI UX: placeholder switched to HTTPS example (`https://docs.example.com`)
- Comments added to justify safety to static analyzers
Files: public/js/adminPanel.js
Refs: #37
---
## Changes 11/3/2025 (v1.8.0)
release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers
Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately.
Adds secure, ACL-aware ONLYOFFICE support throughout FileRise:
- **Backend / API**
- New OnlyOfficeController with supported extensions (doc/xls/ppt/pdf etc.), status/config endpoints, and signed download flow.
- New endpoints:
- GET /api/onlyoffice/status.php — reports availability + supported exts.
- GET /api/onlyoffice/config.php — returns DocEditor config (signed URLs, callback).
- GET /api/onlyoffice/signed-download.php — serves signed blobs to DS.
- Effective config/overrides: env/constant wins; supports docsOrigin, publicOrigin, and jwtSecret; status gated on presence of origin+secret.
- Public origin resolution (BASE_URL/proxy aware) for absolute URLs.
- **Admin config / UI**
- AdminPanel gets a new “ONLYOFFICE” section with Enable toggle, Document Server Origin, masked JWT Secret, and “Replace” control.
- Built-in connection tester (status, secret presence, callback ping, api.js load, iframe embed) + CSP helper (Apache & Nginx snippets)
- **Frontend integration**
- fileEditor detects OO capability via /api/onlyoffice/status and routes supported types to the DocEditor; loads DocsAPI dynamically.
- editFile() short-circuits to openOnlyOffice when applicable; includes live dark/light theme sync where supported.
- fileListView pulls status once on load to drive UI decisions (e.g., editing affordances).
- **AdminModel / config**
- Adds onlyoffice {enabled, docsOrigin, publicOrigin} defaults and update path, with jwtSecret persisted (kept unless explicitly replaced).
- Optional constants in config.php to override and debug.
- **Security & UX notes**
- Editor access remains ACL-checked (read/edit) and uses absolute, signed URLs surfaced via controller.
- Admin UI never echoes secrets; “Replace” toggles explicit updates only.
- CSP helper makes it straightforward to permit api.js + iframe + XHR to your DS.
- **Docs/Styling**
- Minor CSS touch-ups around hover states and modal layout.
---
## Changes 11/2/2025 (v1.7.5)
release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50)
release(v1.7.5): retrigger CI bump (no code changes)
release(v1.7.5): retrigger CI bump ensure up to date
### Security/headers
- Tighten CSP: pin the inline pre-theme snippet with a script-src SHA-256 and keep everything else on 'self'.
- Improve cache policy for versioned assets: force 1y + immutable and add s-maxage for CDNs; also avoid HSTS redirects on local/dev hosts.
### Previews & editor
- Remove hardcoded `/uploads/` paths; always build preview URLs via the API (respects UPLOAD_DIR/ACL).
- Use the API URL for gallery prev/next and file-menu “Preview” to fix 404s on custom storage roots.
- Editor now probes size safely (HEAD → Range 0-0 fallback) before fetching, then fetches with credentials.
### Login, theming & UX polish
- Pre-theme inline boot sets `dark-mode` + background early; swap to `[hidden]`/`unhide()` instead of inline `display:none`.
- Add full-screen loading overlay with quick fade and proper color-scheme; prevent white/black flash on theme flips.
- Refactor app/login reveal flow in `main.js` (`revealAppAndHideOverlay`, `authed` path, setup wizard).
### HTML/CSS & perf
- Make Bootstrap/Styles/Roboto critical (plain `<link rel="stylesheet">`); keep fonts as true preloads; modulepreload app entry.
- Export a `__CSS_PROMISE__` from `defer-css.js` for sites that still promote preloads.
- Header logo marked `fetchpriority="high"` for faster first paint.
- Normalize dark-mode selectors to `.dark-mode` scope (admin panel, etc.).
### Manual Deploy script
- Add `scripts/filerise-deploy.sh`: idempotent rsync-based deploy with writable dirs preserved, optional Composer install, and PHP-FPM/Apache reloads.
### Notes
- If you change the inline pre-theme snippet, update the CSP hash accordingly.
---
## Changes 10/31/2025 (v1.7.4)
release(v1.7.4): login hint replace toast + fix unauth boot
main.js
- Added isDemoHost() and showLoginTip(message).
- In the unauth branch, call showLoginTip('Please log in to continue').
- Removed ensureToastReady() + showToast('please_log_in_to_continue') in the unauth path to avoid loading toast/DOM utils before auth.
---
## Changes 10/31/2025 (v1.7.3)
release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth

View File

@@ -10,7 +10,7 @@
[![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) • [FAQ](#faq--troubleshooting)
**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)
**Elevate your File Management** A modern, self-hosted web file manager.
Upload, organize, and share files or folders through a sleek, responsive web interface.
@@ -21,6 +21,8 @@ Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage
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.
New: 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. Where supported by your Document Server, users can add **comments/annotations** to documents (and PDFs). Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If youre on ≤1.4.x, please upgrade.
**10/25/2025 Video demo:**
@@ -74,6 +76,8 @@ With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2
- 📝 **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).
@@ -342,8 +346,49 @@ https://your-host/webdav.php/
---
## 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"
```
**Nginx**
```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;
```
**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.
---
## FAQ / Troubleshooting
- **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.
- **“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.
- **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.
@@ -399,6 +444,20 @@ Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
## Dependencies
### ONLYOFFICE integration
FileRise can open office documents using a self-hosted ONLYOFFICE Document Server.
- **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)
- **FileRise license unaffected:** FileRise communicates with ONLYOFFICE over standard HTTP and loads `api.js` from the configured Document Server at runtime; FileRise does not redistribute ONLYOFFICE code.
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
#### Security / CSP
If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src`, `connect-src`). The Admin panel shows a ready-to-copy line for Apache/Nginx.
### PHP Libraries
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)

View File

@@ -25,6 +25,13 @@ if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
define('ACL_INHERIT_ON_CREATE', true);
// ONLYOFFICE integration overrides (uncomment and set as needed)
/*
define('ONLYOFFICE_ENABLED', false);
define('ONLYOFFICE_JWT_SECRET', 'test123456');
define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server
define('ONLYOFFICE_DEBUG', true);
*/
// Encryption helpers
function encryptData($data, $encryptionKey)

View File

@@ -1,35 +1,46 @@
# --------------------------------
# Base: safe in most environments
# FileRise portable .htaccess
# --------------------------------
Options -Indexes
DirectoryIndex index.html
<IfModule mod_authz_core.c>
<FilesMatch "^\.">
# Block dotfiles like .env, .git, etc., but allow ACME under .well-known
<FilesMatch "^\.(?!well-known(?:/|$))">
Require all denied
</FilesMatch>
</IfModule>
# ---------------- Rewrites ----------------
<IfModule mod_rewrite.c>
RewriteEngine On
# --- HTTPS redirect ---
# Use ONE of these blocks.
# Never redirect local/dev hosts
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
RewriteRule ^ - [L]
# A) Direct TLS on this server (enable this if Apache terminates HTTPS here)
#RewriteCond %{HTTPS} off
# Let ACME http-01 pass BEFORE any redirect (needed for auto-renew)
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
RewriteRule - - [L]
# HTTPS redirect (enable ONE of these, comment the other)
# A) Direct TLS on this server
#RewriteCond %{HTTPS} !=on
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# B) Behind a reverse proxy/CDN that sets X-Forwarded-Proto
# B) Behind reverse proxy that sets X-Forwarded-Proto
#RewriteCond %{HTTP:X-Forwarded-Proto} =http [OR]
#RewriteCond %{HTTP:X-Forwarded-Proto} ^$
#RewriteCond %{HTTPS} !=on
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Don't interfere with ACME/http-01 if you do your own certs
#RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
#RewriteRule - - [L]
# Mark versioned assets (?v=...) with env flag for caching rules below
RewriteCond %{QUERY_STRING} (^|&)v= [NC]
RewriteRule ^ - [E=IS_VER:1]
</IfModule>
# --- MIME types (fonts/SVG/ESM) ---
# ---------------- MIME types ----------------
<IfModule mod_mime.c>
AddType font/woff2 .woff2
AddType font/woff .woff
@@ -37,7 +48,7 @@ RewriteEngine On
AddType application/javascript .mjs
</IfModule>
# --- Security headers ---
# ---------------- Security headers ----------------
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
@@ -48,54 +59,54 @@ RewriteEngine On
Header always set Expect-CT "max-age=86400, enforce"
Header always set Cross-Origin-Resource-Policy "same-origin"
Header always set X-Permitted-Cross-Domain-Policies "none"
# HSTS only when actually on HTTPS
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" "expr=%{HTTPS} == 'on'"
# CSP (modules, blobs, workers, etc.)
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
# HSTS only when HTTPS (safe for .htaccess)
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" env=HTTPS
# CSP — keep this SHA-256 in sync with your inline pre-theme script
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='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
</IfModule>
# --- Caching (query-string based, no env vars needed) ---
# ---------------- Caching ----------------
<IfModule mod_headers.c>
# HTML/PHP: no cache (only if PHP didnt already set it)
# HTML/PHP: no cache
<FilesMatch "\.(html?|php)$">
Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
Header setifempty Pragma "no-cache"
Header setifempty Expires "0"
</FilesMatch>
# version.js: always non-cacheable
# version.js: never cache
<FilesMatch "^js/version\.js$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
# Unversioned JS/CSS: 1 hour
# JS/CSS: long cache if ?v= present, else 1h
<FilesMatch "\.(?:m?js|css)$">
Header set Cache-Control "public, max-age=3600, must-revalidate" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
Header set Cache-Control "public, max-age=3600, must-revalidate" env=!IS_VER
</FilesMatch>
# Unversioned static (images/fonts): 7 days
# Images/fonts: long cache if ?v= present, else 7d
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
Header set Cache-Control "public, max-age=604800" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
</FilesMatch>
# Versioned assets (?v=...): 1 year + immutable
<FilesMatch "\.(?:m?js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
Header setifempty Cache-Control "public, max-age=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/"
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
Header set Cache-Control "public, max-age=604800" env=!IS_VER
</FilesMatch>
</IfModule>
# --- Compression ---
# ---------------- Compression ----------------
<IfModule mod_brotli.c>
BrotliCompressionQuality 5
# Do NOT set BrotliCompressionQuality in .htaccess (vhost/server only)
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
</IfModule>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
</IfModule>
# --- Disable TRACE ---
# ---------------- Disable TRACE ----------------
<IfModule mod_rewrite.c>
RewriteCond %{REQUEST_METHOD} ^TRACE
RewriteRule .* - [F]
RewriteRule .* - [F]
</IfModule>

View File

@@ -0,0 +1,7 @@
<?php
// public/api/media/getProgress.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
$ctl = new MediaController();
$ctl->getProgress();

View File

@@ -0,0 +1,7 @@
<?php
// public/api/media/getViewedMap.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
$ctl = new MediaController();
$ctl->getViewedMap();

View File

@@ -0,0 +1,7 @@
<?php
// public/api/media/updateProgress.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
$ctl = new MediaController();
$ctl->updateProgress();

View File

@@ -0,0 +1,13 @@
<?php
/**
* @OA\Post(
* path="/api/onlyoffice/callback.php",
* summary="ONLYOFFICE save callback",
* tags={"ONLYOFFICE"},
* @OA\Response(response=200, description="OK / error JSON")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
(new OnlyOfficeController())->callback();

View File

@@ -0,0 +1,17 @@
<?php
/**
* @OA\Get(
* path="/api/onlyoffice/config.php",
* summary="Get editor config for a file (signed URLs, callback)",
* tags={"ONLYOFFICE"},
* @OA\Parameter(name="folder", in="query", @OA\Schema(type="string")),
* @OA\Parameter(name="file", in="query", @OA\Schema(type="string")),
* @OA\Response(response=200, description="Editor config"),
* @OA\Response(response=403, description="Forbidden"),
* @OA\Response(response=404, description="Disabled / Not found")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
(new OnlyOfficeController())->config();

View File

@@ -0,0 +1,15 @@
<?php
/**
* @OA\Get(
* path="/api/onlyoffice/signed-download.php",
* summary="Serve a signed file blob to ONLYOFFICE",
* tags={"ONLYOFFICE"},
* @OA\Parameter(name="tok", in="query", required=true, @OA\Schema(type="string")),
* @OA\Response(response=200, description="File stream"),
* @OA\Response(response=403, description="Signature/expiry invalid")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
(new OnlyOfficeController())->signedDownload();

View File

@@ -0,0 +1,13 @@
<?php
/**
* @OA\Get(
* path="/api/onlyoffice/status.php",
* summary="ONLYOFFICE availability & supported extensions",
* tags={"ONLYOFFICE"},
* @OA\Response(response=200, description="Status JSON")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
(new OnlyOfficeController())->status();

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,6 +1,38 @@
/* ===========================================================
GENERAL STYLES & BASE LAYOUT
=========================================================== */
/* Reserve stable space for header + main */
:root { --header-h: 55px; }
.header-container { min-height: var(--header-h); }
img.logo{ width:50px; height:50px; display:block; } /* belt & suspenders for logo sizing */
/* Hidden-but-reserved utility (no clicks) */
.is-visually-hidden {
visibility: hidden;
pointer-events: none;
}
/* After auth: show app, hide login */
#fr-login-tip {
min-height: 40px; /* reserve space */
max-width: 520px;
margin: 8px auto 0;
border-radius: 8px;
padding: 10px 12px;
text-align: left;
margin-bottom: 10px;
}
.main-wrapper{
display:flex; /* or grid—flex is fine here */
gap:5px;
align-items:flex-start;
}
/* GENERAL STYLES */
body {
@@ -24,8 +56,8 @@ body {
padding-left: 4px !important;
}@media (min-width: 1300px) {
.container-fluid {
padding-left: 30px !important;
padding-right: 30px !important;
padding-left: 20px !important;
padding-right: 20px !important;
}}
@media (max-width: 600px) {
.zones-toggle { left: 85px !important; }
@@ -37,11 +69,6 @@ body {
/************************************************************/
/* FLEXBOX HEADER: LOGO, TITLE, BUTTONS FIXED */
/************************************************************/
.header-logo .logo {
display:block;
max-width:100%;
height:auto; /* keep aspect ratio; HTML attrs set the intrinsic box */
}
.btn-login {
margin-top: 10px;
}/* Color overrides */
@@ -65,7 +92,7 @@ body {
background-color: #2196F3;
transition: background-color 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}body.dark-mode .header-container {
}.dark-mode .header-container {
background-color: #1f1f1f;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
}#darkModeIcon {
@@ -77,7 +104,7 @@ body {
}.header-logo svg {
height: 50px;
width: auto;
}body.dark-mode header {
}.dark-mode header {
background-color: #1f1f1f;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
}.header-left {
@@ -163,7 +190,7 @@ body {
padding: 10px;
box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.2);
}/* Folder Help Tooltip - Dark Mode */
body.dark-mode .folder-help-tooltip {
.dark-mode .folder-help-tooltip {
background-color: #333 !important;
color: #eee !important;
border: 1px solid #555 !important;
@@ -171,7 +198,7 @@ body {
-webkit-text-fill-color: orange !important;
color: inherit !important;
padding-right: 10px !important;
}body.dark-mode #folderHelpBtn i.material-icons.folder-help-icon {
}.dark-mode #folderHelpBtn i.material-icons.folder-help-icon {
-webkit-text-fill-color: #ffa500 !important;
padding-right: 10px !important;
}/************************************************************/
@@ -221,8 +248,8 @@ body {
.material-icons.gallery-icon {
color: black;
margin-right: 5px;
}body.dark-mode .material-icons.folder-icon,
body.dark-mode .material-icons.gallery-icon {
}.dark-mode .material-icons.folder-icon,
.dark-mode .material-icons.gallery-icon {
color: white;
margin-right: 5px;
}.remove-file-btn {
@@ -253,23 +280,23 @@ body {
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 4px;
}body.dark-mode #loginForm {
}.dark-mode #loginForm {
background-color: #2c2c2c;
color: #e0e0e0;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.2);
}body.dark-mode #loginForm input {
}.dark-mode #loginForm input {
background-color: #333;
color: #fff;
border: 1px solid #555;
}body.dark-mode #loginForm label {
}.dark-mode #loginForm label {
color: #ddd;
}body.dark-mode #loginForm button {
}.dark-mode #loginForm button {
background-color: #007bff;
color: white;
border: none;
}body.dark-mode #loginForm button:hover {
}.dark-mode #loginForm button:hover {
background-color: #0056b3;
}/* ===========================================================
CARDS & MODALS
@@ -292,7 +319,7 @@ body {
border: 1px solid #ccc;
border-radius: 4px;
}/* Override modal content for dark mode */
body.dark-mode #restoreFilesModal .modal-content {
.dark-mode #restoreFilesModal .modal-content {
background: #2c2c2c !important;
border: 1px solid #555 !important;
color: #f0f0f0;
@@ -376,7 +403,7 @@ body {
transform: translate(-50%, -70%);
}}
body.dark-mode .modal .modal-content {
.dark-mode .modal .modal-content {
background-color: #2c2c2c;
color: #e0e0e0;
border-color: #444;
@@ -405,10 +432,10 @@ body {
background-color: #ff4d4d;
box-shadow: 0px 0px 6px rgba(255, 77, 77, 0.8);
transform: scale(1.05);
}body.dark-mode .editor-close-btn {
}.dark-mode .editor-close-btn {
background-color: rgba(0, 0, 0, 0.7);
color: #ff6666;
}body.dark-mode .editor-close-btn:hover {
}.dark-mode .editor-close-btn:hover {
background-color: #ff6666;
color: #000;
}/* Editor Modal */
@@ -434,7 +461,7 @@ body {
width: 100% !important;
resize: none !important;
overflow: auto !important;
}body.dark-mode .editor-modal {
}.dark-mode .editor-modal {
background-color: #2c2c2c;
color: #e0e0e0;
border-color: #444;
@@ -459,7 +486,7 @@ body {
}.editor-title {
margin: 0;
line-height: 33px;
}body.dark-mode .editor-header {
}.dark-mode .editor-header {
background-color: #2c2c2c;
}@media (max-width: 600px) {
.editor-title {
@@ -527,9 +554,9 @@ body {
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s ease, color 0.2s ease;
}body.dark-mode .material-icons.pauseResumeBtn {
}.dark-mode .material-icons.pauseResumeBtn {
color: white !important;
}body.dark-mode .material-icons.pauseResumeBtn:hover {
}.dark-mode .material-icons.pauseResumeBtn:hover {
background-color: rgba(255, 215, 0, 0.3);
color: #fff;
}body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
@@ -632,15 +659,15 @@ body {
}#createBtn {
background-color: #007bff;
color: white;
}body.dark-mode .dropdown-menu {
}.dark-mode .dropdown-menu {
background-color: #2c2c2c !important;
border-color: #444 !important;
color: #e0e0e0!important;
}body.dark-mode .dropdown-menu .dropdown-item {
}.dark-mode .dropdown-menu .dropdown-item {
color: #e0e0e0 !important;
}.dropdown-item:hover {
background-color: rgba(0,0,0,0.05);
}body.dark-mode .dropdown-item:hover {
}.dark-mode .dropdown-item:hover {
background-color: rgba(255,255,255,0.1);
}#fileList button.edit-btn {
background-color: #007bff;
@@ -661,7 +688,7 @@ body {
background-color: transparent;
}#fileList table tr:hover {
background-color: #e0e0e0;
}body.dark-mode #fileList table tr:hover {
}.dark-mode #fileList table tr:hover {
background-color: #444;
}#fileListTitle {
white-space: normal !important;
@@ -679,7 +706,7 @@ body {
box-shadow: none;
border: none !important;
outline: none !important;
}body.dark-mode #fileList table tr {
}.dark-mode #fileList table tr {
box-shadow: none;
border: none !important;
outline: none !important;
@@ -763,7 +790,7 @@ body {
color: inherit;
cursor: pointer;
padding: 0;
}#loginForm,
}
#uploadForm {
display: none;
}.folder-actions {
@@ -824,7 +851,7 @@ body {
color: #fff;
}.row-selected {
background-color: #f2f2f2 !important;
}body.dark-mode .row-selected {
}.dark-mode .row-selected {
background-color: #444 !important;
color: #fff !important;
}.custom-prev-next-btn {
@@ -838,11 +865,11 @@ body {
cursor: pointer;
}.custom-prev-next-btn:hover:not(:disabled) {
background-color: #d5d5d5;
}body.dark-mode .custom-prev-next-btn {
}.dark-mode .custom-prev-next-btn {
background-color: #444;
color: #fff;
border: none;
}body.dark-mode .custom-prev-next-btn:hover:not(:disabled) {
}.dark-mode .custom-prev-next-btn:hover:not(:disabled) {
background-color: #555;
}#customToast {
position: fixed;
@@ -879,6 +906,10 @@ body {
line-height: 1 !important;
vertical-align: middle !important;
}#fileListContainer {
border: 1px solid #e0e0e0;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 8px;
max-width: 100%;
padding-bottom: 10px !important;
padding-left: 5px !important;
@@ -889,7 +920,7 @@ body {
width: 99%;
}}
body.dark-mode #fileListContainer {
.dark-mode #fileListContainer {
background-color: #2c2c2c;
color: #e0e0e0;
border: 1px solid #444;
@@ -938,7 +969,7 @@ body {
align-items: stretch;
}.file-list-actions .action-btn {
width: 100%;
height: 10px !important;
}.modal-content {
width: 95%;
margin: 20% auto;
@@ -965,7 +996,7 @@ body {
#copySelectedBtn:hover,
#moveSelectedBtn:hover,
#downloadZipBtn:hover,
#extractZipBtn:hover
#extractZipBtn:hover,
#customChooseBtn:hover {
transform: scale(1.08);
box-shadow: 0 2px 10px rgba(0,0,0,.12);
@@ -1122,12 +1153,12 @@ body {
background-color: #d0d0d0;
border-radius: 4px;
padding: 2px 4px;
}body.dark-mode .folder-option.selected {
}.dark-mode .folder-option.selected {
background-color: #444;
color: #fff;
border-radius: 4px;
padding: 2px 4px;
}body.dark-mode .folder-option:hover {
}.dark-mode .folder-option:hover {
background-color: #333;
color: #fff;
padding: 2px 4px;
@@ -1167,7 +1198,7 @@ body {
display: inline-flex !important;
}}
body.dark-mode .image-preview-modal-content {
.dark-mode .image-preview-modal-content {
background: #2c2c2c;
border-color: #444;
}.image-modal-img {
@@ -1204,13 +1235,13 @@ body {
width: 600px !important;
max-width: 90vw !important;
/* ensures it doesn't exceed the viewport width */
}body.dark-mode .close-image-modal {
}.dark-mode .close-image-modal {
background-color: rgba(0, 0, 0, 0.6);
color: #ff6666;
}body.dark-mode .close-image-modal:hover {
}.dark-mode .close-image-modal:hover {
background-color: #ff6666;
color: #000;
}body.dark-mode .image-preview-modal-content {
}.dark-mode .image-preview-modal-content {
background: #2c2c2c;
border-color: #444;
}.page-indicator {
@@ -1223,7 +1254,7 @@ body {
margin-right: 0;
margin-left: 0;
font-size: 32px;
}body.dark-mode .file-icon {
}.dark-mode .file-icon {
color: white;
}.bottom-select {
display: inline-block;
@@ -1321,36 +1352,36 @@ body {
}/* ===========================================================
DARK MODE STYLES
=========================================================== */
body.dark-mode {
.dark-mode {
background-color: #121212;
color: #e0e0e0;
}body.dark-mode .container {
}.dark-mode .container {
background-color: transparent !important;
}body.dark-mode .btn-primary {
}.dark-mode .btn-primary {
background-color: #007bff;
color: #fff;
border-color: #007bff;
}body.dark-mode .btn-secondary {
}.dark-mode .btn-secondary {
background-color: #6c757d;
color: #fff;
border-color: #6c757d;
}body.dark-mode .btn-danger {
}.dark-mode .btn-danger {
background-color: #dc3545;
color: #fff;
border-color: #dc3545;
}body.dark-mode .modal .modal-content,
body.dark-mode .editor-modal {
}.dark-mode .modal .modal-content,
.dark-mode .editor-modal {
background-color: #2c2c2c;
color: #e0e0e0;
border: 1px solid #444;
}body.dark-mode table {
}.dark-mode table {
background-color: #2c2c2c;
color: #e0e0e0;
}body.dark-mode table tr:hover {
}.dark-mode table tr:hover {
background-color: #444;
}body.dark-mode #uploadProgressContainer .progress {
}.dark-mode #uploadProgressContainer .progress {
background-color: #333;
}body.dark-mode #uploadProgressContainer .progress-bar {
}.dark-mode #uploadProgressContainer .progress-bar {
background-color: #007bff;
color: #e0e0e0;
}.dark-mode-toggle {
@@ -1367,10 +1398,10 @@ body {
background-color: rgba(255, 255, 255, 0.15) !important;
}.dark-mode-toggle:active {
background-color: rgba(255, 255, 255, 0.25) !important;
}body.dark-mode .dark-mode-toggle {
}.dark-mode .dark-mode-toggle {
background-color: transparent !important;
color: white !important;
}body.dark-mode .dark-mode-toggle:hover {
}.dark-mode .dark-mode-toggle:hover {
background-color: rgba(255, 255, 255, 0.15) !important;
}.dark-mode-toggle:focus {
outline: none !important;
@@ -1397,29 +1428,29 @@ body {
}.folder-help-list {
margin: 0;
padding-left: 20px;
}body.dark-mode .folder-help-details {
}.dark-mode .folder-help-details {
color: #ddd;
background-color: #2c2c2c;
border-color: #444;
}body.dark-mode .folder-help-summary {
}.dark-mode .folder-help-summary {
color: #ddd;
background: #2c2c2c;
}body.dark-mode .folder-help-icon {
}.dark-mode .folder-help-icon {
color: #f6a72c;
font-size: 20px;
}body.dark-mode .CodeMirror {
}.dark-mode .CodeMirror {
background: #1e1e1e !important;
color: #ffffff !important;
}body.dark-mode .CodeMirror-cursor {
}.dark-mode .CodeMirror-cursor {
border-left: 2px solid #ffffff !important;
}body.dark-mode .CodeMirror-gutters {
}.dark-mode .CodeMirror-gutters {
background: #252526 !important;
border-right: 1px solid #444 !important;
}body.dark-mode .CodeMirror-linenumber {
}.dark-mode .CodeMirror-linenumber {
color: #aaaaaa !important;
}body.dark-mode .CodeMirror-selected {
}.dark-mode .CodeMirror-selected {
background: rgba(255, 255, 255, 0.2) !important;
}body.dark-mode .CodeMirror-matchingbracket {
}.dark-mode .CodeMirror-matchingbracket {
background-color: rgba(255, 255, 255, 0.1) !important;
border-bottom: 1px solid #ffffff !important;
}.zoom_in,
@@ -1454,7 +1485,7 @@ body {
}.drop-hover {
background-color: #e0e0e0;
border: 1px dashed #666;
}body.dark-mode .drop-hover {
}.dark-mode .drop-hover {
background-color: rgba(255, 255, 255, 0.1) !important;
border-bottom: 1px dashed #ffffff !important;
}#restoreFilesList li {
@@ -1466,35 +1497,33 @@ body {
transform: translateY(-3px) !important;
}#restoreFilesList li label {
margin-left: 8px !important;
}body.dark-mode #fileContextMenu {
}.dark-mode #fileContextMenu {
background-color: #2c2c2c !important;
border: 1px solid #555 !important;
color: #e0e0e0 !important;
}body.dark-mode #fileContextMenu div {
}.dark-mode #fileContextMenu div {
color: #e0e0e0 !important;
}#folderContextMenu {
font-family: Arial, sans-serif;
font-size: 14px;
}body.dark-mode #folderContextMenu {
}.dark-mode #folderContextMenu {
background-color: #2c2c2c;
border-color: #555;
color: #e0e0e0;
}.main-wrapper {
display: flex;
flex-direction: row;
}.drop-target-sidebar {
display: none;
width: 50px;
transition: width 0.3s ease;
background-color: #f8f9fa;
border-right: 2px dashed #1565C0;
padding: 10px;
margin-top: 10px;
margin-left: 10px;
}@media (min-width: 769px) {
.drop-target-sidebar {
display: block;
}}
.drop-target-sidebar.active {
.drop-target-sidebar.active,
.drag-header.active {
width: 350px;
height: 750px;
}.main-column {
flex: 1;
transition: margin-left 0.3s ease;
@@ -1563,13 +1592,12 @@ body {
}#sidebarDropArea,
#uploadFolderRow {
background-color: transparent;
}#sidebarDropArea {
display: none;
}body.dark-mode #sidebarDropArea,
body.dark-mode #uploadFolderRow {
}.dark-mode #sidebarDropArea,
.dark-mode #uploadFolderRow {
background-color: transparent;
}body.dark-mode #sidebarDropArea.highlight,
body.dark-mode #uploadFolderRow.highlight {
}.dark-mode #sidebarDropArea.highlight,
.dark-mode #uploadFolderRow.highlight {
background-color: #333;
border: 2px dashed #555;
color: #fff;
@@ -1588,7 +1616,7 @@ body {
max-width: 900px;
width: 100%;
margin: 0 auto;
}body.dark-mode .card {
}.dark-mode .card {
background-color: #2c2c2c;
color: #e0e0e0;
border: 1px solid #444;
@@ -1606,17 +1634,17 @@ body {
}.admin-panel-content {
background: #fff;
color: #000;
}body.dark-mode .admin-panel-content {
}.dark-mode .admin-panel-content {
background: #2c2c2c;
color: #e0e0e0;
border: 1px solid #444;
}body.dark-mode .admin-panel-content input,
body.dark-mode .admin-panel-content select,
body.dark-mode .admin-panel-content textarea {
}.dark-mode .admin-panel-content input,
.dark-mode .admin-panel-content select,
.dark-mode .admin-panel-content textarea {
background: #3a3a3a;
color: #e0e0e0;
border: 1px solid #555;
}body.dark-mode .admin-panel-content label {
}.dark-mode .admin-panel-content label {
color: #e0e0e0;
}#openChangePasswordModalBtn {
width: max-content;
@@ -1637,7 +1665,7 @@ body {
color: var(--download-spinner-color, #000);
}body:not(.dark-mode) {
--download-spinner-color: #000;
}body.dark-mode {
}.dark-mode {
--download-spinner-color: #fff;
}.rise-effect {
transform: translateY(-20px);
@@ -1672,7 +1700,7 @@ body {
background-color: transparent;
transition: width 0.3s ease;
box-sizing: border-box;
}body.dark-mode .header-drop-zone.drag-active {
}.dark-mode .header-drop-zone.drag-active {
background-color: #333;
border: 2px dashed #555;
color: #fff;
@@ -1703,16 +1731,16 @@ body {
line-height: 1;
margin: 0;
padding: 0;
}body.dark-mode #fileSummary {
}.dark-mode #fileSummary {
color: white;
}#searchIcon {
border-radius: 4px;
padding: 4px 8px;
}body.dark-mode #searchIcon {
}.dark-mode #searchIcon {
background-color: #444;
border: 1px solid #555;
color: #fff;
}body.dark-mode #searchInput {
}.dark-mode #searchInput {
background-color: #333;
color: #e0e0e0;
border: 1px solid #555;
@@ -1737,11 +1765,11 @@ body {
.btn-icon:focus {
background: rgba(0, 0, 0, 0.1);
outline: none;
}body.dark-mode .btn-icon .material-icons,
body.dark-mode #searchIcon .material-icons {
}.dark-mode .btn-icon .material-icons,
.dark-mode #searchIcon .material-icons {
color: #fff;
}body.dark-mode .btn-icon:hover,
body.dark-mode .btn-icon:focus {
}.dark-mode .btn-icon:hover,
.dark-mode .btn-icon:focus {
background: rgba(255, 255, 255, 0.1);
}.user-dropdown {
position: relative;
@@ -1772,12 +1800,12 @@ body {
display: inline-block;
vertical-align: middle;
margin-left: 0.25rem;
}body.dark-mode .user-dropdown .user-menu {
}.dark-mode .user-dropdown .user-menu {
background: #2c2c2c;
border-color: #444;
}body.dark-mode .user-dropdown .user-menu .item {
}.dark-mode .user-dropdown .user-menu .item {
color: #e0e0e0;
}body.dark-mode .user-dropdown .user-menu .item:hover {
}.dark-mode .user-dropdown .user-menu .item:hover {
background: rgba(255,255,255,0.1);
}.user-dropdown .dropdown-username {
margin: 0 8px;
@@ -1814,7 +1842,7 @@ body {
}:root {
--perm-caret: #444;
}/* light */
body.dark-mode {
.dark-mode {
--perm-caret: #ccc;
}/* dark */
@@ -1827,7 +1855,7 @@ body {
background-color 160ms cubic-bezier(.2,.0,.2,1);
}:root {
--toggle-icon-color: #333;
}body.dark-mode {
}.dark-mode {
--toggle-icon-color: #eee;
}#zonesToggleFloating .material-icons,
#zonesToggleFloating .material-icons-outlined,
@@ -1872,4 +1900,29 @@ body {
background: #fafafa;
border-color: #e2e2e2;
}
/* media modal polish */
.media-modal { background: var(--panel-bg, #121212); }
.media-header-bar .btn { padding: 6px 10px; }
.gallery-nav-btn { color: #fff; opacity: 0.85; }
.gallery-nav-btn:hover { opacity: 1; transform: scale(1.05); }
/* badges */
.status-badge {
display: inline-block;
margin-left: 6px;
padding: 2px 6px;
font-size: 11px;
line-height: 1.3;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.15);
background: rgba(255,255,255,.08);
color: #fff;
}
.status-badge.watched {
border-color: rgba(34,197,94,.35); /* green-ish */
background: rgba(34,197,94,.15);
}
.status-badge.progress {
border-color: rgba(250,204,21,.35); /* amber-ish */
background: rgba(250,204,21,.15);
}

View File

@@ -2,65 +2,38 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FileRise</title>
<!-- Icons -->
<link rel="icon" type="image/png" href="/assets/logo.png">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<!-- App meta -->
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
<meta name="csrf-token" content="">
<meta name="share-url" content="">
<meta name="theme-color" content="#0b5ed7">
<!-- Minimal critical CSS only (keeps CSP clean, no inline JS) -->
<style>
.main-wrapper{display:none}
#loadingOverlay{position:fixed;inset:0;background:var(--bg-color,#fff);z-index:9999;display:flex;align-items:center;justify-content:center}
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
<style id="pretheme-css">
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
</style>
<!-- CSS: preload, then promote via tiny external JS (no inline onload) -->
<link rel="preload" as="style" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
<link rel="preload" as="style" href="/css/styles.css?v={{APP_QVER}}">
<!-- Fonts: preload only those used above the fold -->
<link rel="preload" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" as="font" type="font/woff2" crossorigin>
<!-- Do NOT preload material icons unless needed above the fold -->
<!-- Non-blocking stylesheet promotion (external to satisfy CSP) -->
<script src="/js/defer-css.js?v={{APP_QVER}}" defer></script>
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="theme-color" content="#0b5ed7"><meta name="color-scheme" content="light dark">
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
<!-- Base CSS as a fallback if JS is disabled -->
<noscript>
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
</noscript>
<!-- Preload font CSS (non-blocking) -->
<link rel="preload" as="style" href="/css/vendor/roboto.css?v={{APP_QVER}}">
<link rel="preload" as="style" href="/css/vendor/material-icons.css?v={{APP_QVER}}">
<!-- Vendor JS (keep defer; theyre not modules) -->
<!-- Critical CSS -->
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
<!-- Fonts (ok to keep as real preloads) -->
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
<!-- Vendor & version (deferred) -->
<script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}" defer></script>
<!-- IMPORTANT: Remove CodeMirror here; lazy-load it inside your editor route/module. -->
<!-- Version marker (non-blocking) -->
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
<!-- App entry: start fetching early, execute after parse -->
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}">
<script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
<!-- App entry -->
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
</head>
<body>
<div id="appRoot" style="visibility:hidden">
<header class="header-container">
<div class="header-left">
<a href="index.html">
<div class="header-logo">
@@ -68,19 +41,21 @@
src="/assets/logo.svg?v={{APP_QVER}}"
alt="FileRise"
class="logo"
width="50" height="50"
width="50"
height="50"
decoding="async"
fetchpriority="low"
fetchpriority="high"
/>
</div>
</a>
</div>
<div class="header-title">
<h1 data-i18n-key="header_title">FileRise</h1>
<h1>FileRise</h1>
</div>
<div class="header-right">
<div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;">
<!-- Your header drop zone -->
<div id="headerDropArea" class="header-drop-zone"></div>
<div class="header-buttons">
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
@@ -115,7 +90,7 @@
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
<i class="material-icons">person_remove</i>
</button>
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode">
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode" hidden>
<span class="material-icons" id="darkModeIcon">
dark_mode
</span>
@@ -124,15 +99,18 @@
</div>
</div>
</header>
<div id="loadingOverlay"></div>
<!-- Custom Toast Container -->
<div id="customToast"></div>
<div id="hiddenCardsContainer" style="display:none;"></div>
<main id="main">
<main id="main" hidden>
<div class="row mt-4" id="loginForm">
<div class="col-12">
<div id="loginBox" class="login-box">
<div id="fr-login-tip" class="alert alert-info login-hint" role="status" aria-live="polite" style="display:none;"></div>
<form id="authForm" method="post">
<div class="form-group">
<label for="loginUsername" data-i18n-key="user">User:</label>
@@ -158,13 +136,14 @@
HTTP
Login</a>
</div>
<div>
</div>
</div>
</main>
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
<div class="main-wrapper">
<div class="main-wrapper" hidden>
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
<div id="sidebarDropArea" class="drop-target-sidebar"></div>
<!-- Main Column -->
@@ -505,7 +484,6 @@
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -170,9 +170,9 @@ async function safeJson(res) {
max-width: none !important;
}
}
body.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
body.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
body.dark-mode .form-control::placeholder { color:#888; }
.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
.dark-mode .form-control::placeholder { color:#888; }
.section-header {
background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:4px; font-weight:bold;
@@ -181,8 +181,8 @@ async function safeJson(res) {
.section-header:first-of-type { margin-top:0; }
.section-header.collapsed .material-icons { transform:rotate(-90deg); }
.section-header .material-icons { transition:transform .3s; color:#444; }
body.dark-mode .section-header { background:#3a3a3a; color:#eee; }
body.dark-mode .section-header .material-icons { color:#ccc; }
.dark-mode .section-header { background:#3a3a3a; color:#eee; }
.dark-mode .section-header .material-icons { color:#ccc; }
.section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; }
@@ -193,7 +193,7 @@ async function safeJson(res) {
border:2px solid transparent; transition:all .3s;
}
#adminPanelModal .editor-close-btn:hover { color:#fff; background:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); }
body.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; }
.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; }
.action-row { display:flex; justify-content:space-between; margin-top:15px; }
@@ -210,7 +210,7 @@ async function safeJson(res) {
border-radius: 6px;
padding: 0;
}
body.dark-mode .folder-access-list { border-color:#555; }
.dark-mode .folder-access-list { border-color:#555; }
.folder-access-header,
.folder-access-row {
@@ -228,7 +228,7 @@ async function safeJson(res) {
font-weight: 700;
border-bottom: 1px solid rgba(0,0,0,0.12);
}
body.dark-mode .folder-access-header { background:#2c2c2c; }
.dark-mode .folder-access-header { background:#2c2c2c; }
.folder-access-row { border-bottom: 1px solid rgba(0,0,0,0.06); }
.folder-access-row:last-child { border-bottom: none; }
@@ -257,8 +257,8 @@ async function safeJson(res) {
color: #2064ff;
margin-left: 6px;
}
body.dark-mode .inherited-row { background: rgba(32,132,255,0.12); }
body.dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; }
.dark-mode .inherited-row { background: rgba(32,132,255,0.12); }
.dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; }
@media (max-width: 900px) {
.folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
@@ -274,7 +274,7 @@ async function safeJson(res) {
/* nicer thin scrollbar (supported browsers) */
.folder-cell::-webkit-scrollbar{ height:8px; }
.folder-cell::-webkit-scrollbar-thumb{ background:rgba(0,0,0,.25); border-radius:4px; }
body.dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); }
.dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); }
/* Badge now doesn't clip; let the wrapper handle scroll */
.folder-badge{
@@ -491,6 +491,7 @@ export function openAdminPanel() {
{ id: "headerSettings", label: t("header_settings") },
{ id: "loginOptions", label: t("login_options") },
{ id: "webdav", label: "WebDAV Access" },
{ id: "onlyoffice", label: "ONLYOFFICE" },
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
{ id: "shareLinks", label: t("manage_shared_links") },
@@ -514,7 +515,7 @@ export function openAdminPanel() {
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks", "sponsor"]
["userManagement", "headerSettings", "loginOptions", "webdav", "onlyoffice", "upload", "oidc", "shareLinks", "sponsor"]
.forEach(id => {
document.getElementById(id + "Header")
.addEventListener("click", () => toggleSection(id));
@@ -574,6 +575,312 @@ export function openAdminPanel() {
</div>
`;
// ONLYOFFICE Content
const hasOOSecret = !!(config.onlyoffice && config.onlyoffice.hasJwtSecret);
window.__HAS_OO_SECRET = hasOOSecret;
document.getElementById("onlyofficeContent").innerHTML = `
<div class="form-group">
<input type="checkbox" id="ooEnabled" />
<label for="ooEnabled">Enable ONLYOFFICE integration</label>
</div>
<div class="form-group">
<label for="ooDocsOrigin">Document Server Origin:</label>
<input type="url" id="ooDocsOrigin" class="form-control" placeholder="e.g. https://docs.example.com" />
<small class="text-muted">Must be reachable by your browser (for API.js) and by FileRise (for callbacks). Avoid “localhost”.</small>
</div>
${renderMaskedInput({ id: "ooJwtSecret", label: "JWT Secret", hasValue: hasOOSecret, isSecret: true })}
`;
wireReplaceButtons(document.getElementById("onlyofficeContent"));
// --- Test ONLYOFFICE block ---
const testBox = document.createElement("div");
testBox.className = "card";
testBox.style.marginTop = "12px";
testBox.innerHTML = `
<div class="card-body">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
<strong>Test ONLYOFFICE connection</strong>
<button type="button" id="ooTestBtn" class="btn btn-sm btn-primary">Run tests</button>
<span id="ooTestSpinner" style="display:none;">⏳</span>
</div>
<ul id="ooTestResults" class="list-unstyled" style="margin:0;"></ul>
<small class="text-muted">These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding.</small>
</div>
`;
document.getElementById("onlyofficeContent").appendChild(testBox);
// Util: tiny UI helpers for results
function ooRow(label, status, detail = "") {
const li = document.createElement("li");
li.style.margin = "6px 0";
const icon = status === "ok" ? "✅" : status === "warn" ? "⚠️" : "❌";
li.innerHTML = `<span style="min-width:1.2em;display:inline-block">${icon}</span> <strong>${label}</strong>${detail ? ` — <span>${detail}</span>` : ""}`;
return li;
}
function ooClear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
// --- ONLYOFFICE URL sanitizers ---
function getTrustedDocsOrigin(raw) {
try {
const u = new URL(String(raw || "").trim());
if (!/^https?:$/.test(u.protocol)) return null; // only http/https
if (u.username || u.password) return null; // no creds in URL
return u.origin; // scheme://host[:port]
} catch {
return null;
}
}
function buildOnlyOfficeApiUrl(origin) {
// fixed path; caller already validated/normalized origin
const u = new URL('/web-apps/apps/api/documents/api.js', origin);
u.searchParams.set('probe', String(Date.now()));
return u.toString();
}
// Probes that dont explode your state
async function ooProbeScript(docsOrigin) {
return new Promise(resolve => {
const base = getTrustedDocsOrigin(docsOrigin);
if (!base) { resolve({ ok: false }); return; }
const src = buildOnlyOfficeApiUrl(base);
const s = document.createElement('script');
s.id = 'ooProbeScript';
s.async = true;
s.src = src;
// If you set a CSP nonce in a <meta name="csp-nonce" content="...">, attach it:
const nonce = document.querySelector('meta[name="csp-nonce"]')?.content;
if (nonce) s.setAttribute('nonce', nonce);
const cleanup = () => { try { s.remove(); } catch {} };
s.onload = () => { cleanup(); resolve({ ok: true }); };
s.onerror = () => { cleanup(); resolve({ ok: false }); };
// codeql[js/xss-through-dom]: the origin is validated (http/https, no creds),
// and the path is fixed to ONLYOFFICE api.js via URL(), so this is safe.
document.head.appendChild(s);
});
}
async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
return new Promise(resolve => {
const base = getTrustedDocsOrigin(docsOrigin);
if (!base) { resolve({ ok: false }); return; }
const f = document.createElement('iframe');
f.id = 'ooProbeFrame';
f.src = base; // only the sanitized origin
f.style.display = 'none';
// Optional: keep it extra constrained while probing.
// If your DS needs broader privileges, you can drop sandbox.
// f.sandbox = 'allow-same-origin allow-scripts';
const cleanup = () => { try { f.remove(); } catch {} };
const t = setTimeout(() => { cleanup(); resolve({ ok: false, timeout: true }); }, timeoutMs);
f.onload = () => { clearTimeout(t); cleanup(); resolve({ ok: true }); };
f.onerror = () => { clearTimeout(t); cleanup(); resolve({ ok: false }); };
// codeql[js/xss-through-dom]: src is constrained to a validated http/https origin.
document.body.appendChild(f);
});
}
// Main test runner
async function runOnlyOfficeTests() {
const spinner = document.getElementById('ooTestSpinner');
const out = document.getElementById('ooTestResults');
const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim();
spinner.style.display = 'inline';
ooClear(out);
// 1) FileRise status
let statusOk = false, statusJson = null;
try {
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
statusJson = await r.json().catch(() => ({}));
if (r.ok) {
if (statusJson.enabled) {
out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready'));
statusOk = true;
} else {
// Disabled usually means missing secret or origin; well dig deeper below.
out.appendChild(ooRow('FileRise status', 'warn', 'Disabled — check JWT Secret and Document Server Origin'));
}
} else {
out.appendChild(ooRow('FileRise status', 'fail', `HTTP ${r.status}`));
}
} catch (e) {
out.appendChild(ooRow('FileRise status', 'fail', (e && e.message) || 'Network error'));
}
// 2) Secret presence (fresh read)
try {
const cfg = await fetch('/api/admin/getConfig.php', { credentials: 'include', cache: 'no-store' }).then(r => r.json());
const hasSecret = !!(cfg.onlyoffice && cfg.onlyoffice.hasJwtSecret);
out.appendChild(ooRow('JWT secret saved', hasSecret ? 'ok' : 'fail', hasSecret ? 'Present' : 'Missing'));
} catch {
out.appendChild(ooRow('JWT secret saved', 'warn', 'Could not verify'));
}
// 3) Callback reachable (basic ping)
try {
const r = await fetch('/api/onlyoffice/callback.php?ping=1', { credentials: 'include', cache: 'no-store' });
if (r.ok) out.appendChild(ooRow('Callback endpoint', 'ok', 'Reachable'));
else out.appendChild(ooRow('Callback endpoint', 'fail', `HTTP ${r.status}`));
} catch {
out.appendChild(ooRow('Callback endpoint', 'fail', 'Network error'));
}
// Early sanity on origin
if (!/^https?:\/\//i.test(docsOrigin)) {
out.appendChild(ooRow('Document Server Origin', 'fail', 'Enter a valid http(s) origin (e.g., https://docs.example.com)'));
spinner.style.display = 'none';
return;
}
// 4a) Can browser load api.js (also surfaces CSP script-src issues)
const sRes = await ooProbeScript(docsOrigin);
out.appendChild(ooRow('Load api.js', sRes.ok ? 'ok' : 'fail', sRes.ok ? 'Loaded' : 'Blocked (check CSP script-src and origin)'));
// 4b) Can browser embed DS in an iframe (CSP frame-src)
const fRes = await ooProbeFrame(docsOrigin);
out.appendChild(ooRow('Embed DS iframe', fRes.ok ? 'ok' : 'fail', fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)'));
// Optional tip if we see common red flags
if (!statusOk || !sRes.ok || !fRes.ok) {
const tip = document.createElement('li');
tip.style.marginTop = '8px';
tip.innerHTML = "💡 <em>Tip:</em> Use the CSP helper above to include your Document Server in <code>script-src</code>, <code>connect-src</code>, and <code>frame-src</code>.";
out.appendChild(tip);
}
spinner.style.display = 'none';
}
// Wire the button
document.getElementById('ooTestBtn')?.addEventListener('click', runOnlyOfficeTests);
// Append CSP help box
// --- CSP help box (replace your whole block with this) ---
const ooSec = document.getElementById("onlyofficeContent");
const cspHelp = document.createElement("div");
cspHelp.className = "alert alert-info";
cspHelp.style.marginTop = "12px";
cspHelp.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<strong>Content-Security-Policy help</strong>
<button type="button" id="copyOoCsp" class="btn btn-sm btn-outline-secondary">Copy</button>
<button type="button" id="selectOoCsp" class="btn btn-sm btn-outline-secondary">Select</button>
</div>
<div class="form-text" style="margin-bottom:8px;">
Add/replace this line in <code>public/.htaccess</code> (Apache). It allows loading ONLYOFFICE's <code>api.js</code>,
embedding the editor iframe, and letting the script make XHR to your Document Server.
</div>
<pre id="ooCspSnippet" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7;"></pre>
<div class="form-text" style="margin-top:8px;">
If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead.
Also note: if your site is <code>https://</code>, your ONLYOFFICE server must be <code>https://</code> too,
otherwise the browser will block it as mixed content.
</div>
<details style="margin-top:8px;">
<summary>Nginx equivalent</summary>
<pre id="ooCspSnippetNginx" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7; margin-top:6px;"></pre>
</details>
`;
ooSec.appendChild(cspHelp);
const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=";
function buildCspApache(originRaw) {
const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, '');
const api = `${o}/web-apps/apps/api/documents/api.js`;
return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
}
function buildCspNginx(originRaw) {
const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, '');
const api = `${o}/web-apps/apps/api/documents/api.js`;
return `add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}" always;`;
}
const ooDocsInput = document.getElementById("ooDocsOrigin");
const cspPre = document.getElementById("ooCspSnippet");
const cspPreNgx = document.getElementById("ooCspSnippetNginx");
function refreshCsp() {
const raw = (ooDocsInput?.value || "").trim();
const base = getTrustedDocsOrigin(raw) || raw; // fall back to raw so users see their input
cspPre.textContent = buildCspApache(base);
cspPreNgx.textContent = buildCspNginx(base);
}
ooDocsInput?.addEventListener("input", refreshCsp);
refreshCsp();
// ---- Copy helpers (with robust fallback) ----
async function copyToClipboard(text) {
// Best path: async clipboard API in a secure context (https/localhost)
if (navigator.clipboard && window.isSecureContext) {
try { await navigator.clipboard.writeText(text); return true; }
catch (_) { /* fall through */ }
}
// Fallback for http or blocked clipboard: hidden textarea + execCommand
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy'); // deprecated but still widely supported
document.body.removeChild(ta);
return ok;
} catch (_) {
return false;
}
}
function selectElementContents(el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
document.getElementById("copyOoCsp")?.addEventListener("click", async () => {
const txt = (cspPre.textContent || "").trim();
const ok = await copyToClipboard(txt);
if (ok) {
showToast("CSP line copied.");
} else {
// Auto-select so the user can Ctrl/Cmd+C as a last resort
try { selectElementContents(cspPre); } catch { }
const reason = window.isSecureContext ? "" : " (page is not HTTPS or localhost)";
showToast("Copy failed" + reason + ". Press Ctrl/Cmd+C to copy.");
}
});
document.getElementById("selectOoCsp")?.addEventListener("click", () => {
try { selectElementContents(cspPre); showToast("Selected — press Ctrl/Cmd+C"); }
catch { /* ignore */ }
});
document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled);
document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : "";
const hasId = !!(config.oidc && config.oidc.hasClientId);
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
@@ -696,10 +1003,24 @@ export function openAdminPanel() {
document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User";
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
// remember lock for handleSave
window.__OO_LOCKED = !!(config.onlyoffice && config.onlyoffice.lockedByPhp);
if (window.__OO_LOCKED) {
const sec = document.getElementById("onlyofficeContent");
sec.querySelectorAll("input,button").forEach(el => el.disabled = true);
const note = document.createElement("div");
note.className = "form-text";
note.style.marginTop = "6px";
note.textContent = "Managed by config.php — edit ONLYOFFICE_* constants there.";
sec.appendChild(note);
}
captureInitialAdminConfig();
} else {
mdl.style.display = "flex";
const hasId = !!(config.oidc && config.oidc.hasClientId);
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
@@ -713,6 +1034,10 @@ export function openAdminPanel() {
if (!hasId) idEl.value = window.currentOIDCConfig?.clientId || "";
if (!hasSecret) secEl.value = window.currentOIDCConfig?.clientSecret || "";
wireReplaceButtons(document.getElementById("oidcContent"));
document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled);
document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : "";
const ooCont = document.getElementById("onlyofficeContent");
if (ooCont) wireReplaceButtons(ooCont);
document.getElementById("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || "";
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || "";
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || '';
@@ -752,6 +1077,30 @@ function handleSave() {
payload.oidc.clientSecret = scEl.value.trim();
}
const ooSecretEl = document.getElementById("ooJwtSecret");
payload.onlyoffice = {
enabled: document.getElementById("ooEnabled").checked,
docsOrigin: document.getElementById("ooDocsOrigin").value.trim()
};
if (ooSecretEl?.dataset.replace === '1' && ooSecretEl.value.trim() !== '') {
payload.onlyoffice.jwtSecret = ooSecretEl.value.trim();
}
// ---- ONLYOFFICE payload ----
if (!window.__OO_LOCKED) {
const ooSecretVal = (document.getElementById("ooJwtSecret")?.value || "").trim();
payload.onlyoffice = {
enabled: document.getElementById("ooEnabled").checked,
docsOrigin: document.getElementById("ooDocsOrigin").value.trim()
};
// If user typed a secret (non-empty), send it (server keeps it if non-empty)
if (ooSecretVal !== "") {
payload.onlyoffice.jwtSecret = ooSecretVal;
}
}
fetch('/api/admin/updateConfig.php', {
method: 'POST',
credentials: 'include',

View File

@@ -1,20 +1,31 @@
// Promote any preloaded styles to real stylesheets without inline handlers (CSP-safe)
document.addEventListener('DOMContentLoaded', () => {
// Promote any preloaded core CSS
document.querySelectorAll('link[rel="preload"][as="style"][href]').forEach(link => {
const href = link.getAttribute('href');
if ([...document.querySelectorAll('link[rel="stylesheet"]')]
.some(s => s.getAttribute('href') === href)) return;
const sheet = document.createElement('link');
sheet.rel = 'stylesheet';
sheet.href = href;
document.head.appendChild(sheet);
});
// /public/js/defer-css.js
// Promote preloaded styles to real stylesheets (CSP-safe) and expose a load promise.
(function () {
if (window.__CSS_PROMISE__) return;
var loads = [];
// Optionally load non-critical icon/extra font CSS after first paint:
const extra = document.createElement('link');
extra.rel = 'stylesheet';
extra.href = '/css/vendor/material-icons.css?v={{APP_QVER}}';
document.head.appendChild(extra);
});
// Promote <link rel="preload" as="style"> IN-PLACE
var preloads = document.querySelectorAll('link[rel="preload"][as="style"]');
for (var i = 0; i < preloads.length; i++) {
var l = preloads[i];
// resolve when it finishes loading as a stylesheet
loads.push(new Promise(function (res) { l.addEventListener('load', res, { once: true }); }));
l.rel = 'stylesheet';
if (!l.media || l.media === 'print') l.media = 'all'; // be explicit
l.removeAttribute('as'); // keep some engines happy about "used" preload
}
// Also wait for any existing <link rel="stylesheet"> that haven't finished yet
var styles = document.querySelectorAll('link[rel="stylesheet"]');
for (var j = 0; j < styles.length; j++) {
var s = styles[j];
if (s.sheet) continue; // already applied
loads.push(new Promise(function (res) { s.addEventListener('load', res, { once: true }); }));
}
// Safari quirk: nudge layout so promoted sheets apply immediately
void document.documentElement.offsetHeight;
window.__CSS_PROMISE__ = Promise.all(loads);
})();

View File

@@ -2,6 +2,7 @@
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
// thresholds for editor behavior
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
@@ -14,7 +15,7 @@ const CM_BASE = "/vendor/codemirror/5.65.5/";
const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`;
const CORE = {
js: coreUrl("codemirror.min.js"),
js: coreUrl("codemirror.min.js"),
css: coreUrl("codemirror.min.css"),
themeCss: coreUrl("theme/material-darker.min.css"),
};
@@ -22,30 +23,30 @@ const CORE = {
// Which mode file to load for a given name/mime
const MODE_URL = {
// core/common
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
"css": "mode/css/css.min.js?v={{APP_QVER}}",
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
"css": "mode/css/css.min.js?v={{APP_QVER}}",
"javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}",
// meta / combos
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
"application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}",
// docs / data
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
"properties": "mode/properties/properties.min.js?v={{APP_QVER}}",
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
// shells
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
// languages
"python": "mode/python/python.min.js?v={{APP_QVER}}",
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
"python": "mode/python/python.min.js?v={{APP_QVER}}",
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
};
// Mode dependency graph
@@ -64,6 +65,137 @@ function normalizeModeName(modeOption) {
return name;
}
// ---- ONLYOFFICE integration -----------------------------------------------
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
let __ooCaps = { enabled: false, exts: new Set(), fetched: false };
async function fetchOnlyOfficeCapsOnce() {
if (__ooCaps.fetched) return __ooCaps;
try {
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
if (r.ok) {
const j = await r.json();
__ooCaps.enabled = !!j.enabled;
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
}
} catch { /* ignore; keep defaults */ }
__ooCaps.fetched = true;
return __ooCaps;
}
async function shouldUseOnlyOffice(fileName) {
const { enabled, exts } = await fetchOnlyOfficeCapsOnce();
return enabled && exts.has(getExt(fileName));
}
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
let src =
srcFromConfig ||
(originFromConfig ? originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js'
: (window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js'));
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
await loadScriptOnce(src);
}
async function openOnlyOffice(fileName, folder) {
let editor; // make visible to the whole function
try {
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
const resp = await fetch(url, { credentials: 'include' });
const text = await resp.text();
let cfg;
try { cfg = JSON.parse(text); } catch {
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
}
if (!resp.ok) throw new Error(cfg.error || `ONLYOFFICE config HTTP ${resp.status}`);
// Must be absolute
const docUrl = cfg?.document?.url;
const cbUrl = cfg?.editorConfig?.callbackUrl;
if (!/^https?:\/\//i.test(docUrl || '') || !/^https?:\/\//i.test(cbUrl || '')) {
throw new Error(`Config URLs must be absolute. document.url='${docUrl}', callbackUrl='${cbUrl}'`);
}
// Load DocsAPI if needed
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
// Modal
const modal = document.createElement('div');
modal.id = 'ooEditorModal';
modal.classList.add('modal', 'editor-modal');
modal.setAttribute('tabindex', '-1');
modal.innerHTML = `
<div class="editor-header">
<h3 class="editor-title">
${t("editing")}: ${escapeHTML(fileName)}
</h3>
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">&times;</button>
</div>
<div class="editor-body" style="flex:1;min-height:200px">
<div id="oo-editor" style="width:100%;height:100%"></div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
modal.focus();
// Well fill this after wiring the toggle, so destroy() can unhook it
let removeThemeListener = () => {};
const destroy = () => {
try { editor?.destroyEditor?.(); } catch {}
try { removeThemeListener(); } catch {}
try { modal.remove(); } catch {}
};
modal.addEventListener('keydown', e => { if (e.key === 'Escape') destroy(); });
document.getElementById('closeEditorX')?.addEventListener('click', destroy);
// Let DS request closing
cfg.events = Object.assign({}, cfg.events, { onRequestClose: destroy });
// Initial theme
const isDark =
document.documentElement.classList.contains('dark-mode') ||
/^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
cfg.editorConfig = cfg.editorConfig || {};
cfg.editorConfig.customization = Object.assign(
{},
cfg.editorConfig.customization,
{ uiTheme: isDark ? 'theme-dark' : 'theme-light' } // <- correct key/value
);
// Launch editor
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
// Live theme switching (ONLYOFFICE v7.2+ supports setTheme)
const darkToggle = document.getElementById('darkModeToggle');
const onDarkToggle = () => {
const nowDark = document.documentElement.classList.contains('dark-mode');
if (editor && typeof editor.setTheme === 'function') {
editor.setTheme(nowDark ? 'dark' : 'light');
}
};
if (darkToggle) {
darkToggle.addEventListener('click', onDarkToggle);
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
}
} catch (e) {
console.error('[ONLYOFFICE] failed to open:', e);
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
}
}
// ---- /ONLYOFFICE integration ----------------------------------------------
const _loadedScripts = new Set();
const _loadedCss = new Set();
let _corePromise = null;
@@ -195,29 +327,48 @@ function observeModalResize(modal) {
}
export { observeModalResize };
export function editFile(fileName, folder) {
export async function editFile(fileName, folder) {
// destroy any previous editor
let existingEditor = document.getElementById("editorContainer");
if (existingEditor) existingEditor.remove();
const folderUsed = folder || window.currentFolder || "root";
const folderPath = folderUsed === "root"
? "uploads/"
: "uploads/" + folderUsed.split("/").map(encodeURIComponent).join("/") + "/";
const fileUrl = folderPath + encodeURIComponent(fileName) + "?t=" + new Date().getTime();
const fileUrl = buildPreviewUrl(folderUsed, fileName);
fetch(fileUrl, { method: "HEAD" })
.then(response => {
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
if (await shouldUseOnlyOffice(fileName)) {
await openOnlyOffice(fileName, folderUsed);
return;
}
// Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET.
async function probeSize(url) {
try {
const h = await fetch(url, { method: "HEAD", credentials: "include" });
const len = h.headers.get("content-length") ?? h.headers.get("Content-Length");
if (len && !Number.isNaN(parseInt(len, 10))) return parseInt(len, 10);
} catch { }
try {
const r = await fetch(url, {
method: "GET",
headers: { Range: "bytes=0-0" },
credentials: "include"
});
// Content-Range: bytes 0-0/12345
const cr = r.headers.get("content-range") ?? r.headers.get("Content-Range");
const m = cr && cr.match(/\/(\d+)\s*$/);
if (m) return parseInt(m[1], 10);
} catch { }
return null;
}
probeSize(fileUrl)
.then(sizeBytes => {
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
throw new Error("File too large.");
}
return response;
return fetch(fileUrl, { credentials: "include" });
})
.then(() => fetch(fileUrl))
.then(response => {
if (!response.ok) throw new Error("HTTP error! Status: " + response.status);
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
@@ -269,8 +420,8 @@ export function editFile(fileName, folder) {
// Keep buttons responsive even before editor exists
const decBtn = document.getElementById("decreaseFont");
const incBtn = document.getElementById("increaseFont");
decBtn.addEventListener("click", () => {});
incBtn.addEventListener("click", () => {});
decBtn.addEventListener("click", () => { });
incBtn.addEventListener("click", () => { });
// Theme + mode selection
const isDarkMode = document.body.classList.contains("dark-mode");

View File

@@ -34,6 +34,25 @@ import {
export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true };
// onnlyoffice
let OO_ENABLED = false;
let OO_EXTS = new Set();
export async function initOnlyOfficeCaps() {
try {
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
if (!r.ok) throw 0;
const j = await r.json();
OO_ENABLED = !!j.enabled;
OO_EXTS = new Set(Array.isArray(j.exts) ? j.exts : []);
} catch {
OO_ENABLED = false;
OO_EXTS = new Set();
}
}
// Hide "Edit" for files >10 MiB
const MAX_EDIT_BYTES = 10 * 1024 * 1024;
@@ -138,7 +157,121 @@ function wireSelectAll(fileListContent) {
}
return body ?? {};
}
// ---- Viewed badges (table + gallery) ----
// ---------- Badge factory (center text vertically) ----------
function makeBadge(state) {
if (!state) return null;
const el = document.createElement('span');
el.className = 'status-badge';
el.style.cssText = [
'display:inline-flex',
'align-items:center',
'justify-content:center',
'vertical-align:middle',
'margin-left:6px',
'padding:2px 8px',
'min-height:18px',
'line-height:1',
'border-radius:999px',
'font-size:.78em',
'border:1px solid rgba(0,0,0,.2)',
'background:rgba(0,0,0,.06)'
].join(';');
if (state.completed) {
el.classList.add('watched');
el.textContent = (t('watched') || t('viewed') || 'Watched');
el.style.borderColor = 'rgba(34,197,94,.45)';
el.style.background = 'rgba(34,197,94,.12)';
el.style.color = '#22c55e';
return el;
}
if (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)));
el.classList.add('progress');
el.textContent = `${pct}%`;
el.style.borderColor = 'rgba(245,158,11,.45)';
el.style.background = 'rgba(245,158,11,.12)';
el.style.color = '#f59e0b';
return el;
}
return null;
}
// ---------- Public: set/clear badges for one file (table + gallery) ----------
function applyBadgeToDom(name, state) {
const safe = CSS.escape(name);
// Table
document.querySelectorAll(`tr[data-file-name="${safe}"] .name-cell, tr[data-file-name="${safe}"] .file-name-cell`)
.forEach(cell => {
cell.querySelector('.status-badge')?.remove();
const b = makeBadge(state);
if (b) cell.appendChild(b);
});
// Gallery
document.querySelectorAll(`.gallery-card[data-file-name="${safe}"] .gallery-file-name`)
.forEach(title => {
title.querySelector('.status-badge')?.remove();
const b = makeBadge(state);
if (b) title.appendChild(b);
});
}
export function setFileWatchedBadge(name, watched = true) {
applyBadgeToDom(name, watched ? { completed: true } : null);
}
export function setFileProgressBadge(name, seconds, duration) {
if (duration > 0 && seconds >= 0) {
applyBadgeToDom(name, { seconds, duration, completed: seconds >= duration - 1 });
} else {
applyBadgeToDom(name, null);
}
}
export async function refreshViewedBadges(folder) {
let map = null;
try {
const res = await fetch(`/api/media/getViewedMap.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`, { credentials: 'include' });
const j = await res.json();
map = j?.map || null;
} catch { /* ignore */ }
// Clear any existing badges
document.querySelectorAll(
'#fileList tr[data-file-name] .file-name-cell .status-badge, ' +
'#fileList tr[data-file-name] .name-cell .status-badge, ' +
'.gallery-card[data-file-name] .gallery-file-name .status-badge'
).forEach(n => n.remove());
if (!map) return;
// Table rows
document.querySelectorAll('#fileList tr[data-file-name]').forEach(tr => {
const name = tr.getAttribute('data-file-name');
const state = map[name];
if (!state) return;
const cell = tr.querySelector('.name-cell, .file-name-cell');
if (!cell) return;
const badge = makeBadge(state);
if (badge) cell.appendChild(badge);
});
// Gallery cards
document.querySelectorAll('.gallery-card[data-file-name]').forEach(card => {
const name = card.getAttribute('data-file-name');
const state = map[name];
if (!state) return;
const title = card.querySelector('.gallery-file-name');
if (!title) return;
const badge = makeBadge(state);
if (badge) title.appendChild(badge);
});
}
/**
* Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes.
*/
@@ -338,6 +471,7 @@ function searchFiles(searchTerm) {
window.updateRowHighlight = updateRowHighlight;
export async function loadFileList(folderParam) {
await initOnlyOfficeCaps();
const reqId = ++__fileListReqSeq; // latest call wins
const folder = folderParam || "root";
const fileListContainer = document.getElementById("fileList");
@@ -528,6 +662,7 @@ function searchFiles(searchTerm) {
}
updateFileActionButtons();
fileListContainer.style.visibility = "visible";
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
try {
@@ -692,9 +827,14 @@ function searchFiles(searchTerm) {
if (totalFiles > 0) {
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
// Build row with a neutral base, then correct the links/preview below.
let rowHTML = buildFileTableRow(file, fakeBase);
// Give the row an ID so we can patch attributes safely
rowHTML = rowHTML.replace("<tr", `<tr id="file-row-${encodeURIComponent(file.name)}-${startIndex + idx}"`);
const idSafe = encodeURIComponent(file.name) + "-" + (startIndex + idx);
let rowHTML = buildFileTableRow(file, fakeBase);
// add row id + data-file-name, and ensure the name cell also has "name-cell"
rowHTML = rowHTML
.replace("<tr", `<tr id="file-row-${idSafe}" data-file-name="${escapeHTML(file.name)}"`)
.replace('class="file-name-cell"', 'class="file-name-cell name-cell"');
let tagBadgesHTML = "";
if (file.tags && file.tags.length > 0) {
@@ -704,9 +844,13 @@ function searchFiles(searchTerm) {
});
tagBadgesHTML += "</div>";
}
rowsHTML += rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
return p1 + p2 + tagBadgesHTML + p3;
});
rowsHTML += rowHTML.replace(
/(<td\s+class="[^"]*\bfile-name-cell\b[^"]*">)([\s\S]*?)(<\/td>)/,
(m, open, inner, close) => {
// keep the original filename content, then add your tag badges, then close
return `${open}<span class="filename-text">${inner}</span>${tagBadgesHTML}${close}`;
}
);
});
} else {
rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`;
@@ -884,6 +1028,7 @@ function searchFiles(searchTerm) {
});
});
updateFileActionButtons();
document.querySelectorAll("#fileList tbody tr").forEach(row => {
row.setAttribute("draggable", "true");
import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
@@ -894,6 +1039,7 @@ function searchFiles(searchTerm) {
btn.addEventListener("click", e => e.stopPropagation());
});
bindFileListContextMenu();
refreshViewedBadges(folder).catch(() => {});
}
// A helper to compute the max image height based on the current column count.
@@ -1020,6 +1166,7 @@ function searchFiles(searchTerm) {
// card with checkbox, preview, info, buttons
galleryHTML += `
<div class="gallery-card"
data-file-name="${escapeHTML(file.name)}"
style="position:relative; border:1px solid #ccc; padding:5px; text-align:center;">
<input type="checkbox"
class="file-checkbox"
@@ -1216,7 +1363,7 @@ function searchFiles(searchTerm) {
if (window.viewMode === "gallery") renderGalleryView(folder);
else renderFileTable(folder);
};
refreshViewedBadges(folder).catch(() => {});
updateFileActionButtons();
createViewToggleButton();
}
@@ -1328,46 +1475,34 @@ function searchFiles(searchTerm) {
if (!fileName || typeof fileName !== "string") return false;
const dot = fileName.lastIndexOf(".");
if (dot < 0) return false;
const ext = fileName.slice(dot + 1).toLowerCase();
const allowedExtensions = [
"txt", "text", "md", "markdown", "rst",
"html", "htm", "xhtml", "shtml",
"css", "scss", "sass", "less",
"js", "mjs", "cjs", "jsx",
"ts", "tsx",
"json", "jsonc", "ndjson",
"yml", "yaml", "toml", "xml", "plist",
"ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc",
"env", "dotenv",
"csv", "tsv", "tab",
// Your CodeMirror text-based types
const textEditExts = new Set([
"txt","text","md","markdown","rst",
"html","htm","xhtml","shtml",
"css","scss","sass","less",
"js","mjs","cjs","jsx",
"ts","tsx",
"json","jsonc","ndjson",
"yml","yaml","toml","xml","plist",
"ini","conf","config","cfg","cnf","properties","props","rc",
"env","dotenv",
"csv","tsv","tab",
"log",
"sh", "bash", "zsh", "ksh", "fish",
"bat", "cmd",
"ps1", "psm1", "psd1",
"py", "pyw",
"rb",
"pl", "pm",
"go",
"rs",
"java",
"kt", "kts",
"scala", "sc",
"groovy", "gradle",
"c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx",
"m", "mm",
"swift",
"cs", "fs", "fsx",
"dart",
"lua",
"r", "rmd",
"sql",
"vue", "svelte",
"twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade"
];
"sh","bash","zsh","ksh","fish",
"bat","cmd",
"ps1","psm1","psd1",
"py","pyw","rb","pl","pm","go","rs","java","kt","kts",
"scala","sc","groovy","gradle",
"c","h","cpp","cxx","cc","hpp","hh","hxx",
"m","mm","swift","cs","fs","fsx","dart","lua","r","rmd",
"sql","vue","svelte","twig","mustache","hbs","handlebars","ejs","pug","jade"
]);
return allowedExtensions.includes(ext);
if (textEditExts.has(ext)) return true; // CodeMirror
if (OO_ENABLED && OO_EXTS.has(ext)) return true; // ONLYOFFICE types if enabled
return false;
}
// Expose global functions for pagination and preview.

View File

@@ -1,7 +1,7 @@
// 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 { previewFile } from './filePreview.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}}';
@@ -39,11 +39,11 @@ export function showFileContextMenu(x, y, menuItems) {
});
menu.appendChild(menuItem);
});
menu.style.left = x + "px";
menu.style.top = y + "px";
menu.style.display = "block";
const menuRect = menu.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (menuRect.bottom > viewportHeight) {
@@ -62,7 +62,7 @@ export function hideFileContextMenu() {
export function fileListContextMenuHandler(e) {
e.preventDefault();
let row = e.target.closest("tr");
if (row) {
const checkbox = row.querySelector(".file-checkbox");
@@ -71,9 +71,9 @@ export function fileListContextMenuHandler(e) {
updateRowHighlight(checkbox);
}
}
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
let menuItems = [
{ label: t("create_file"), action: () => openCreateFileModal() },
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
@@ -81,14 +81,14 @@ export function fileListContextMenuHandler(e) {
{ 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"),
@@ -100,36 +100,33 @@ export function fileListContextMenuHandler(e) {
}
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";
const folderPath = folder === "root"
? "uploads/"
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
previewFile(folderPath + encodeURIComponent(file.name) + "?t=" + new Date().getTime(), file.name);
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);
}
@@ -140,7 +137,7 @@ export function bindFileListContextMenu() {
}
}
document.addEventListener("click", function(e) {
document.addEventListener("click", function (e) {
const menu = document.getElementById("fileContextMenu");
if (menu && menu.style.display === "block") {
hideFileContextMenu();
@@ -148,9 +145,9 @@ document.addEventListener("click", function(e) {
});
// Rebind context menu after file table render.
(function() {
(function () {
const originalRenderFileTable = window.renderFileTable;
window.renderFileTable = function(folder) {
window.renderFileTable = function (folder) {
originalRenderFileTable(folder);
bindFileListContextMenu();
};

View File

@@ -1,14 +1,19 @@
// filePreview.js
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
import { fileData } from './fileListView.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { fileData, setFileProgressBadge, setFileWatchedBadge } from './fileListView.js?v={{APP_QVER}}';
// Build a preview URL that always goes through the API layer (respects ACLs/UPLOAD_DIR)
export function buildPreviewUrl(folder, name) {
const f = (!folder || folder === '') ? 'root' : String(folder);
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
}
/* -------------------------------- Share modal (existing) -------------------------------- */
export function openShareModal(file, folder) {
// Remove any existing modal
const existing = document.getElementById("shareModal");
if (existing) existing.remove();
// Build the modal
const modal = document.createElement("div");
modal.id = "shareModal";
modal.classList.add("modal");
@@ -45,18 +50,9 @@ export function openShareModal(file, folder) {
</div>
<p style="margin-top:15px;">${t("password_optional")}</p>
<input
type="text"
id="sharePassword"
placeholder="${t("password_optional")}"
style="width:100%;padding:5px;"
/>
<input type="text" id="sharePassword" placeholder="${t("password_optional")}" style="width:100%;padding:5px;"/>
<button
id="generateShareLinkBtn"
class="btn btn-primary"
style="margin-top:15px;"
>
<button id="generateShareLinkBtn" class="btn btn-primary" style="margin-top:15px;">
${t("generate_share_link")}
</button>
@@ -73,48 +69,32 @@ export function openShareModal(file, folder) {
document.body.appendChild(modal);
modal.style.display = "block";
// Close handler
document.getElementById("closeShareModal")
.addEventListener("click", () => modal.remove());
document.getElementById("closeShareModal").addEventListener("click", () => modal.remove());
document.getElementById("shareExpiration").addEventListener("change", e => {
const container = document.getElementById("customExpirationContainer");
container.style.display = e.target.value === "custom" ? "block" : "none";
});
// Show/hide custom-duration inputs
document.getElementById("shareExpiration")
.addEventListener("change", e => {
const container = document.getElementById("customExpirationContainer");
container.style.display = e.target.value === "custom" ? "block" : "none";
});
document.getElementById("generateShareLinkBtn").addEventListener("click", () => {
const sel = document.getElementById("shareExpiration");
let value, unit;
// Generate share link
document.getElementById("generateShareLinkBtn")
.addEventListener("click", () => {
const sel = document.getElementById("shareExpiration");
let value, unit;
if (sel.value === "custom") {
value = parseInt(document.getElementById("customExpirationValue").value, 10);
unit = document.getElementById("customExpirationUnit").value;
} else {
value = parseInt(sel.value, 10);
unit = "minutes";
}
if (sel.value === "custom") {
value = parseInt(document.getElementById("customExpirationValue").value, 10);
unit = document.getElementById("customExpirationUnit").value;
} else {
value = parseInt(sel.value, 10);
unit = "minutes";
}
const password = document.getElementById("sharePassword").value;
const password = document.getElementById("sharePassword").value;
fetch("/api/file/createShareLink.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({
folder,
file: file.name,
expirationValue: value,
expirationUnit: unit,
password
})
})
fetch("/api/file/createShareLink.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder, file: file.name, expirationValue: value, expirationUnit: unit, password })
})
.then(res => res.json())
.then(data => {
if (data.token) {
@@ -122,349 +102,491 @@ export function openShareModal(file, folder) {
document.getElementById("shareLinkInput").value = url;
document.getElementById("shareLinkDisplay").style.display = "block";
} else {
showToast(t("error_generating_share") + ": " + (data.error||"Unknown"));
showToast(t("error_generating_share") + ": " + (data.error || "Unknown"));
}
})
.catch(err => {
console.error(err);
showToast(t("error_generating_share"));
});
});
});
// Copy to clipboard
document.getElementById("copyShareLinkBtn")
.addEventListener("click", () => {
const input = document.getElementById("shareLinkInput");
input.select();
document.execCommand("copy");
showToast(t("link_copied"));
});
document.getElementById("copyShareLinkBtn").addEventListener("click", () => {
const input = document.getElementById("shareLinkInput");
input.select();
document.execCommand("copy");
showToast(t("link_copied"));
});
}
export function previewFile(fileUrl, fileName) {
let modal = document.getElementById("filePreviewModal");
if (!modal) {
modal = document.createElement("div");
modal.id = "filePreviewModal";
Object.assign(modal.style, {
position: "fixed",
top: "0",
left: "0",
width: "100vw",
height: "100vh",
backgroundColor: "rgba(0,0,0,0.7)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: "1000"
});
modal.innerHTML = `
<div class="modal-content image-preview-modal-content" style="position: relative; max-width: 90vw; max-height: 90vh;">
<span id="closeFileModal" class="close-image-modal" style="position: absolute; top: 10px; right: 10px; font-size: 24px; cursor: pointer;">&times;</span>
<h4 class="image-modal-header"></h4>
<div class="file-preview-container" style="position: relative; text-align: center;"></div>
</div>`;
document.body.appendChild(modal);
/* -------------------------------- Media modal viewer -------------------------------- */
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
function closeModal() {
const mediaElements = modal.querySelectorAll("video, audio");
mediaElements.forEach(media => {
media.pause();
if (media.tagName.toLowerCase() !== 'video') {
try { media.currentTime = 0; } catch (e) { }
}
});
modal.remove();
}
function ensureMediaModal() {
let overlay = document.getElementById("filePreviewModal");
if (overlay) return overlay;
document.getElementById("closeFileModal").addEventListener("click", closeModal);
modal.addEventListener("click", function (e) {
if (e.target === modal) {
closeModal();
}
});
overlay = document.createElement("div");
overlay.id = "filePreviewModal";
Object.assign(overlay.style, {
position: "fixed",
inset: "0",
width: "100vw",
height: "100vh",
backgroundColor: "rgba(0,0,0,0.7)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: "1000"
});
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 textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111');
const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)';
const navFg = '#fff';
const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)';
overlay.innerHTML = `
<div class="modal-content media-modal" style="
position: relative;
max-width: 92vw;
max-height: 92vh;
width: 92vw;
box-sizing: border-box;
padding: 12px;
background: ${panelBg};
color: ${textCol};
overflow: hidden;
border-radius: 10px;
">
<div class="media-stage" style="position:relative; display:flex; align-items:center; justify-content:center; height: calc(92vh - 8px);">
<!-- filename badge (top-left) -->
<div class="media-title-badge" style="
position:absolute; top:8px; left:12px; max-width:60vw;
padding:4px 10px; border-radius:10px;
background: ${isDark ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.65)'};
color: ${isDark ? '#fff' : '#111'};
font-weight:600; font-size:13px; line-height:1.3; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; z-index:1002;">
</div>
<!-- top-right actions row (aligned with your X at top:10px) -->
<div class="media-actions-bar" style="
position:absolute; top:10px; right:56px; display:flex; gap:6px; align-items:center; z-index:1002;">
<span class="status-chip" style="
display:none; padding:4px 8px; border-radius:999px; font-size:12px; line-height:1;
border:1px solid rgba(250,204,21,.45); background:rgba(250,204,21,.15); color:#facc15;"></span>
<div class="action-group" style="display:flex; gap:6px;"></div>
</div>
<!-- your absolute close X -->
<span id="closeFileModal" class="close-image-modal" title="${t('close')}">&times;</span>
<!-- centered media -->
<div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div>
<!-- high-contrast prev/next -->
<button class="nav-left" aria-label="${t('previous')||'Previous'}" style="
position:absolute; left:8px; top:50%; transform:translateY(-50%);
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1;
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
text-shadow: 0 1px 2px rgba(0,0,0,.6);
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
box-shadow: 0 2px 8px rgba(0,0,0,.35);"></button>
<button class="nav-right" aria-label="${t('next')||'Next'}" style="
position:absolute; right:8px; top:50%; transform:translateY(-50%);
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1;
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
text-shadow: 0 1px 2px rgba(0,0,0,.6);
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
box-shadow: 0 2px 8px rgba(0,0,0,.35);"></button>
</div>
</div>`;
document.body.appendChild(overlay);
function closeModal() {
try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {}
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
overlay.remove();
}
modal.querySelector("h4").textContent = fileName;
const container = modal.querySelector(".file-preview-container");
container.innerHTML = "";
overlay.querySelector("#closeFileModal").addEventListener("click", closeModal);
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
const extension = fileName.split('.').pop().toLowerCase();
const isImage = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(fileName);
return overlay;
}
function setTitle(overlay, name) {
const el = overlay.querySelector('.media-title-badge');
if (el) el.textContent = name || '';
}
function makeMI(name, title) {
const b = document.createElement('button');
b.className = `material-icons ${name}`;
b.textContent = name; // Material Icons font
b.title = title;
Object.assign(b.style, {
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,.25)",
border: "1px solid rgba(255,255,255,.25)",
cursor: "pointer",
userSelect: "none",
fontSize: "20px",
padding: "0",
borderRadius: "8px",
color: "#fff",
lineHeight: "1"
});
return b;
}
function setNavVisibility(overlay, showPrev, showNext) {
const prev = overlay.querySelector('.nav-left');
const next = overlay.querySelector('.nav-right');
prev.style.display = showPrev ? 'inline-flex' : 'none';
next.style.display = showNext ? 'inline-flex' : 'none';
}
function setRowWatchedBadge(name, watched) {
try {
const cell = document.querySelector(`tr[data-file-name="${CSS.escape(name)}"] .name-cell`);
if (!cell) return;
const old = cell.querySelector('.status-badge.watched');
if (watched) {
if (!old) {
const b = document.createElement('span');
b.className = 'status-badge watched';
b.textContent = t("watched") || t("viewed") || "Watched";
b.style.marginLeft = "6px";
cell.appendChild(b);
}
} else if (old) {
old.remove();
}
} catch {}
}
/* -------------------------------- Entry -------------------------------- */
export function previewFile(fileUrl, fileName) {
const overlay = ensureMediaModal();
const container = overlay.querySelector(".file-preview-container");
const actionWrap = overlay.querySelector(".media-actions-bar .action-group");
const statusChip = overlay.querySelector(".media-actions-bar .status-chip");
// replace nav buttons to clear old listeners
let prevBtn = overlay.querySelector('.nav-left');
let nextBtn = overlay.querySelector('.nav-right');
const newPrev = prevBtn.cloneNode(true);
const newNext = nextBtn.cloneNode(true);
prevBtn.replaceWith(newPrev);
nextBtn.replaceWith(newNext);
prevBtn = newPrev; nextBtn = newNext;
// reset
container.innerHTML = "";
actionWrap.innerHTML = "";
if (statusChip) statusChip.style.display = 'none';
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
overlay._onKey = null;
const folder = window.currentFolder || 'root';
const name = fileName;
const lower = (name || '').toLowerCase();
const isImage = IMG_RE.test(lower);
const isVideo = VID_RE.test(lower);
const isAudio = AUD_RE.test(lower);
setTitle(overlay, name);
/* -------------------- IMAGES -------------------- */
if (isImage) {
// Create the image element with default transform data.
const img = document.createElement("img");
img.src = fileUrl;
img.className = "image-modal-img";
img.style.maxWidth = "80vw";
img.style.maxHeight = "80vh";
img.style.maxWidth = "88vw";
img.style.maxHeight = "88vh";
img.style.transition = "transform 0.3s ease";
img.dataset.scale = 1;
img.dataset.rotate = 0;
img.style.position = 'relative';
img.style.zIndex = '1';
container.appendChild(img);
// Filter gallery images for navigation.
const images = fileData.filter(file => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name));
const zoomInBtn = makeMI('zoom_in', t('zoom_in') || 'Zoom In');
const zoomOutBtn = makeMI('zoom_out', t('zoom_out') || 'Zoom Out');
const rotateLeft = makeMI('rotate_left', t('rotate_left') || 'Rotate Left');
const rotateRight = makeMI('rotate_right', t('rotate_right') || 'Rotate Right');
actionWrap.appendChild(zoomInBtn);
actionWrap.appendChild(zoomOutBtn);
actionWrap.appendChild(rotateLeft);
actionWrap.appendChild(rotateRight);
// Create a flex wrapper to hold left panel, center image, and right panel.
const wrapper = document.createElement('div');
wrapper.className = 'image-wrapper';
wrapper.style.display = 'flex';
wrapper.style.alignItems = 'center';
wrapper.style.justifyContent = 'center';
wrapper.style.position = 'relative';
zoomInBtn.addEventListener('click', (e) => {
e.stopPropagation();
let s = parseFloat(img.dataset.scale) || 1; s += 0.1;
img.dataset.scale = s;
img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`;
});
zoomOutBtn.addEventListener('click', (e) => {
e.stopPropagation();
let s = parseFloat(img.dataset.scale) || 1; s = Math.max(0.1, s - 0.1);
img.dataset.scale = s;
img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`;
});
rotateLeft.addEventListener('click', (e) => {
e.stopPropagation();
let r = parseFloat(img.dataset.rotate) || 0; r = (r - 90 + 360) % 360;
img.dataset.rotate = r;
img.style.transform = `scale(${img.dataset.scale}) rotate(${r}deg)`;
});
rotateRight.addEventListener('click', (e) => {
e.stopPropagation();
let r = parseFloat(img.dataset.rotate) || 0; r = (r + 90) % 360;
img.dataset.rotate = r;
img.style.transform = `scale(${img.dataset.scale}) rotate(${r}deg)`;
});
// --- Left Panel: Contains Zoom controls (top) and Prev button (bottom) ---
const leftPanel = document.createElement('div');
leftPanel.className = 'left-panel';
leftPanel.style.display = 'flex';
leftPanel.style.flexDirection = 'column';
leftPanel.style.justifyContent = 'space-between';
leftPanel.style.alignItems = 'center';
leftPanel.style.width = '60px';
leftPanel.style.height = '100%';
leftPanel.style.zIndex = '10';
const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name));
overlay.mediaType = 'image';
overlay.mediaList = images;
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
setNavVisibility(overlay, images.length > 1, images.length > 1);
// Top container for zoom buttons.
const leftTop = document.createElement('div');
leftTop.style.display = 'flex';
leftTop.style.flexDirection = 'column';
leftTop.style.gap = '4px';
// Zoom In button.
const zoomInBtn = document.createElement('button');
zoomInBtn.className = 'material-icons zoom_in';
zoomInBtn.title = 'Zoom In';
zoomInBtn.style.background = 'transparent';
zoomInBtn.style.border = 'none';
zoomInBtn.style.cursor = 'pointer';
zoomInBtn.textContent = 'zoom_in';
// Zoom Out button.
const zoomOutBtn = document.createElement('button');
zoomOutBtn.className = 'material-icons zoom_out';
zoomOutBtn.title = 'Zoom Out';
zoomOutBtn.style.background = 'transparent';
zoomOutBtn.style.border = 'none';
zoomOutBtn.style.cursor = 'pointer';
zoomOutBtn.textContent = 'zoom_out';
leftTop.appendChild(zoomInBtn);
leftTop.appendChild(zoomOutBtn);
leftPanel.appendChild(leftTop);
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const newFile = overlay.mediaList[overlay.mediaIndex].name;
setTitle(overlay, newFile);
img.dataset.scale = 1;
img.dataset.rotate = 0;
img.style.transform = 'scale(1) rotate(0deg)';
img.src = buildPreviewUrl(folder, newFile);
};
// Bottom container for prev button.
const leftBottom = document.createElement('div');
leftBottom.style.display = 'flex';
leftBottom.style.justifyContent = 'center';
leftBottom.style.alignItems = 'center';
leftBottom.style.width = '100%';
if (images.length > 1) {
const prevBtn = document.createElement("button");
prevBtn.textContent = "";
prevBtn.className = "gallery-nav-btn";
prevBtn.style.background = 'transparent';
prevBtn.style.border = 'none';
prevBtn.style.color = 'white';
prevBtn.style.fontSize = '48px';
prevBtn.style.cursor = 'pointer';
prevBtn.addEventListener("click", function (e) {
e.stopPropagation();
// Safety check:
if (!modal.galleryImages || modal.galleryImages.length === 0) return;
modal.galleryCurrentIndex = (modal.galleryCurrentIndex - 1 + modal.galleryImages.length) % modal.galleryImages.length;
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
modal.querySelector("h4").textContent = newFile.name;
img.src = ((window.currentFolder === "root")
? "uploads/"
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
// Reset transforms.
img.dataset.scale = 1;
img.dataset.rotate = 0;
img.style.transform = 'scale(1) rotate(0deg)';
});
leftBottom.appendChild(prevBtn);
} else {
// Insert an empty placeholder for consistent layout.
leftBottom.innerHTML = '&nbsp;';
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;
}
leftPanel.appendChild(leftBottom);
// --- Center Panel: Contains the image ---
const centerPanel = document.createElement('div');
centerPanel.className = 'center-image-container';
centerPanel.style.flexGrow = '1';
centerPanel.style.textAlign = 'center';
centerPanel.style.position = 'relative';
centerPanel.style.zIndex = '1';
centerPanel.appendChild(img);
overlay.style.display = "flex";
return;
}
// --- Right Panel: Contains Rotate controls (top) and Next button (bottom) ---
const rightPanel = document.createElement('div');
rightPanel.className = 'right-panel';
rightPanel.style.display = 'flex';
rightPanel.style.flexDirection = 'column';
rightPanel.style.justifyContent = 'space-between';
rightPanel.style.alignItems = 'center';
rightPanel.style.width = '60px';
rightPanel.style.height = '100%';
rightPanel.style.zIndex = '10';
// Top container for rotate buttons.
const rightTop = document.createElement('div');
rightTop.style.display = 'flex';
rightTop.style.flexDirection = 'column';
rightTop.style.gap = '4px';
// Rotate Left button.
const rotateLeftBtn = document.createElement('button');
rotateLeftBtn.className = 'material-icons rotate_left';
rotateLeftBtn.title = 'Rotate Left';
rotateLeftBtn.style.background = 'transparent';
rotateLeftBtn.style.border = 'none';
rotateLeftBtn.style.cursor = 'pointer';
rotateLeftBtn.textContent = 'rotate_left';
// Rotate Right button.
const rotateRightBtn = document.createElement('button');
rotateRightBtn.className = 'material-icons rotate_right';
rotateRightBtn.title = 'Rotate Right';
rotateRightBtn.style.background = 'transparent';
rotateRightBtn.style.border = 'none';
rotateRightBtn.style.cursor = 'pointer';
rotateRightBtn.textContent = 'rotate_right';
rightTop.appendChild(rotateLeftBtn);
rightTop.appendChild(rotateRightBtn);
rightPanel.appendChild(rightTop);
// Bottom container for next button.
const rightBottom = document.createElement('div');
rightBottom.style.display = 'flex';
rightBottom.style.justifyContent = 'center';
rightBottom.style.alignItems = 'center';
rightBottom.style.width = '100%';
if (images.length > 1) {
const nextBtn = document.createElement("button");
nextBtn.textContent = "";
nextBtn.className = "gallery-nav-btn";
nextBtn.style.background = 'transparent';
nextBtn.style.border = 'none';
nextBtn.style.color = 'white';
nextBtn.style.fontSize = '48px';
nextBtn.style.cursor = 'pointer';
nextBtn.addEventListener("click", function (e) {
e.stopPropagation();
// Safety check:
if (!modal.galleryImages || modal.galleryImages.length === 0) return;
modal.galleryCurrentIndex = (modal.galleryCurrentIndex + 1) % modal.galleryImages.length;
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
modal.querySelector("h4").textContent = newFile.name;
img.src = ((window.currentFolder === "root")
? "uploads/"
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
// Reset transforms.
img.dataset.scale = 1;
img.dataset.rotate = 0;
img.style.transform = 'scale(1) rotate(0deg)';
});
rightBottom.appendChild(nextBtn);
} else {
// Insert a placeholder so that center remains properly aligned.
rightBottom.innerHTML = '&nbsp;';
}
rightPanel.appendChild(rightBottom);
// Assemble panels into the wrapper.
wrapper.appendChild(leftPanel);
wrapper.appendChild(centerPanel);
wrapper.appendChild(rightPanel);
container.appendChild(wrapper);
// --- Set up zoom controls event listeners ---
zoomInBtn.addEventListener('click', function (e) {
e.stopPropagation();
let scale = parseFloat(img.dataset.scale) || 1;
scale += 0.1;
img.dataset.scale = scale;
img.style.transform = 'scale(' + scale + ') rotate(' + img.dataset.rotate + 'deg)';
});
zoomOutBtn.addEventListener('click', function (e) {
e.stopPropagation();
let scale = parseFloat(img.dataset.scale) || 1;
scale = Math.max(0.1, scale - 0.1);
img.dataset.scale = scale;
img.style.transform = 'scale(' + scale + ') rotate(' + img.dataset.rotate + 'deg)';
});
// Attach rotation control listeners (always present now).
rotateLeftBtn.addEventListener('click', function (e) {
e.stopPropagation();
let rotate = parseFloat(img.dataset.rotate) || 0;
rotate = (rotate - 90 + 360) % 360;
img.dataset.rotate = rotate;
img.style.transform = 'scale(' + img.dataset.scale + ') rotate(' + rotate + 'deg)';
});
rotateRightBtn.addEventListener('click', function (e) {
e.stopPropagation();
let rotate = parseFloat(img.dataset.rotate) || 0;
rotate = (rotate + 90) % 360;
img.dataset.rotate = rotate;
img.style.transform = 'scale(' + img.dataset.scale + ') rotate(' + rotate + 'deg)';
});
// Save gallery details if there is more than one image.
if (images.length > 1) {
modal.galleryImages = images;
modal.galleryCurrentIndex = images.findIndex(f => f.name === fileName);
}
} else {
// Handle non-image file previews.
if (extension === "pdf") {
// build a cachebusted URL
/* -------------------- PDF => new tab -------------------- */
if (lower.endsWith('.pdf')) {
const separator = fileUrl.includes('?') ? '&' : '?';
const urlWithTs = fileUrl + separator + 't=' + Date.now();
// open in a new tab (avoids CSP frame-ancestors)
window.open(urlWithTs, "_blank");
// tear down the just-created modal
const modal = document.getElementById("filePreviewModal");
if (modal) modal.remove();
// stop further preview logic
overlay.remove();
return;
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) {
const video = document.createElement("video");
video.src = fileUrl;
video.controls = true;
video.className = "image-modal-img";
const progressKey = 'videoProgress-' + fileUrl;
video.addEventListener("loadedmetadata", () => {
const savedTime = localStorage.getItem(progressKey);
if (savedTime) {
video.currentTime = parseFloat(savedTime);
}
/* -------------------- 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);
const markBtn = document.createElement('button');
const clearBtn = document.createElement('button');
markBtn.className = 'btn btn-sm btn-success';
clearBtn.className = 'btn btn-sm btn-secondary';
markBtn.textContent = t("mark_as_viewed") || "Mark as viewed";
clearBtn.textContent = t("clear_progress") || "Clear progress";
actionWrap.appendChild(markBtn);
actionWrap.appendChild(clearBtn);
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';
markBtn.style.display = 'none';
clearBtn.style.display = '';
clearBtn.textContent = 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';
statusChip.style.borderColor = 'rgba(250,204,21,.45)';
statusChip.style.background = 'rgba(250,204,21,.15)';
statusChip.style.color = '#facc15';
markBtn.style.display = '';
clearBtn.style.display = '';
clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// No progress
statusChip.style.display = 'none';
markBtn.style.display = '';
clearBtn.style.display = 'none';
}
function bindVideoEvents(nm) {
const nv = video.cloneNode(true);
video.replaceWith(nv);
video = nv;
video.addEventListener("loadedmetadata", async () => {
try {
const state = await getProgress(nm);
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
video.currentTime = state.seconds;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
setFileProgressBadge(nm, seconds, duration);
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
} else {
const ls = localStorage.getItem(lsKey(nm));
if (ls) video.currentTime = parseFloat(ls);
}
renderStatus(state || null);
} catch {
renderStatus(null);
}
});
video.addEventListener("timeupdate", () => {
localStorage.setItem(progressKey, video.currentTime);
video.addEventListener("timeupdate", async () => {
const now = Date.now();
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
lastSaveAt = now;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
sendProgress({ nm, seconds, duration });
setFileProgressBadge(nm, seconds, duration);
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
renderStatus({ seconds, duration, completed: false });
});
video.addEventListener("ended", () => {
localStorage.removeItem(progressKey);
video.addEventListener("ended", async () => {
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
});
container.appendChild(video);
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(fileName)) {
const audio = document.createElement("audio");
audio.src = fileUrl;
audio.controls = true;
audio.className = "audio-modal";
audio.style.maxWidth = "80vw";
container.appendChild(audio);
} else {
container.textContent = "Preview not available for this file type.";
markBtn.onclick = async () => {
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
};
clearBtn.onclick = async () => {
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("progress_cleared") || "Progress cleared");
setFileWatchedBadge(nm, false);
renderStatus(null);
};
}
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const nm = overlay.mediaList[overlay.mediaIndex].name;
setVideoSrc(nm);
bindVideoEvents(nm);
};
if (videos.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
const onKey = (e) => {
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
if (e.key === "ArrowLeft") navigate(-1);
if (e.key === "ArrowRight") navigate(+1);
};
window.addEventListener("keydown", onKey);
overlay._onKey = onKey;
}
setVideoSrc(name);
renderStatus(null);
bindVideoEvents(name);
overlay.style.display = "flex";
return;
}
/* -------------------- AUDIO / OTHER -------------------- */
if (isAudio) {
const audio = document.createElement("audio");
audio.src = fileUrl;
audio.controls = true;
audio.className = "audio-modal";
audio.style.maxWidth = "88vw";
container.appendChild(audio);
overlay.style.display = "flex";
} else {
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
overlay.style.display = "flex";
}
modal.style.display = "flex";
}
// Preserve original functionality.
/* -------------------------------- Small display helper -------------------------------- */
export function displayFilePreview(file, container) {
const actualFile = file.file || file;
if (!(actualFile instanceof File)) {
@@ -472,10 +594,9 @@ export function displayFilePreview(file, container) {
return;
}
container.style.display = "inline-block";
while (container.firstChild) {
container.removeChild(container.firstChild);
}
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(actualFile.name)) {
while (container.firstChild) container.removeChild(container.firstChild);
if (IMG_RE.test(actualFile.name)) {
const img = document.createElement("img");
img.src = URL.createObjectURL(actualFile);
img.classList.add("file-preview-img");
@@ -488,5 +609,6 @@ export function displayFilePreview(file, container) {
}
}
// expose for HTML onclick usage
window.previewFile = previewFile;
window.openShareModal = openShareModal;

View File

@@ -302,7 +302,17 @@ const translations = {
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
"context_move_folder": "Move Folder...",
"context_move_here": "Move Here",
"context_move_cancel": "Cancel Move"
"context_move_cancel": "Cancel Move",
"mark_as_viewed": "Mark as viewed",
"viewed": "Viewed",
"resumed_from": "Resumed from",
"clear_progress": "Clear progress",
"marked_viewed": "Marked as viewed",
"progress_cleared": "Progress cleared",
"previous": "Previous",
"next": "Next",
"watched": "Watched",
"reset_progress": "Reset Progress"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -61,6 +61,33 @@ async function ensureToastReady() {
}
}
function isDemoHost() {
// Handles optional "www." just in case
return location.hostname.replace(/^www\./, '') === 'demo.filerise.net';
}
function showLoginTip(message) {
const tip = document.getElementById('fr-login-tip');
if (!tip) return;
tip.innerHTML = ''; // clear
if (message) tip.append(document.createTextNode(message));
if (location.hostname.replace(/^www\./, '') === 'demo.filerise.net') {
const line = document.createElement('div'); line.style.marginTop = '6px';
const mk = t => { const k = document.createElement('code'); k.textContent = t; return k; };
line.append(document.createTextNode('Demo login — user: '), mk('demo'),
document.createTextNode(' · pass: '), mk('demo'));
tip.append(line);
}
tip.style.display = 'block'; // reveal without shifting layout
}
async function hideOverlaySmoothly(overlay) {
if (!overlay) return;
try { await document.fonts?.ready; } catch { }
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
overlay.style.display = 'none';
}
function wireModalEnterDefault() {
if (window.__FR_FLAGS.wired.enterDefault) return;
window.__FR_FLAGS.wired.enterDefault = true;
@@ -288,7 +315,6 @@ function applyDarkMode({ fromSystemChange = false } = {}) {
let stored = null;
try { stored = localStorage.getItem('darkMode'); } catch { }
// If no stored pref, fall back to system
let isDark = (stored === null)
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
: (stored === '1' || stored === 'true');
@@ -302,15 +328,26 @@ function applyDarkMode({ fromSystemChange = false } = {}) {
el.setAttribute('data-theme', isDark ? 'dark' : 'light');
});
// keep UA chrome & bg consistent post-toggle
const bg = isDark ? '#121212' : '#ffffff';
root.style.backgroundColor = bg;
root.style.colorScheme = isDark ? 'dark' : 'light';
if (body) {
body.style.backgroundColor = bg;
body.style.colorScheme = isDark ? 'dark' : 'light';
}
const mt = document.querySelector('meta[name="theme-color"]');
if (mt) mt.content = bg;
const mcs = document.querySelector('meta[name="color-scheme"]');
if (mcs) mcs.content = isDark ? 'dark light' : 'light dark';
const btn = document.getElementById('darkModeToggle');
const icon = document.getElementById('darkModeIcon');
if (icon) icon.textContent = isDark ? 'light_mode' : 'dark_mode';
if (btn) {
const ttOn = (typeof t === 'function' ? t('switch_to_dark_mode') : 'Switch to dark mode');
const ttOff = (typeof t === 'function' ? t('switch_to_light_mode') : 'Switch to light mode');
const aria = (typeof t === 'function' ? (isDark ? t('light_mode') : t('dark_mode')) : (isDark ? 'Light mode' : 'Dark mode'));
btn.classList.toggle('active', isDark);
btn.setAttribute('aria-label', aria);
btn.setAttribute('title', isDark ? ttOff : ttOn);
@@ -347,6 +384,9 @@ function bindDarkMode() {
// ---------- tiny utils ----------
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
// Safe show/hide that work with both CSS and [hidden]
const unhide = (el) => { if (!el) return; el.removeAttribute('hidden'); el.style.display = ''; };
const hideEl = (el) => { if (!el) return; el.setAttribute('hidden', ''); el.style.display = 'none'; };
const show = (el) => {
if (!el) return;
el.hidden = false; el.classList?.remove('d-none', 'hidden');
@@ -360,28 +400,88 @@ function bindDarkMode() {
};
// ---------- site config / auth ----------
function applySiteConfig(cfg) {
function applySiteConfig(cfg, { phase = 'final' } = {}) {
try {
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
// Always keep <title> correct early (no visual flicker)
document.title = title;
const h1 = document.querySelector('.header-title h1'); if (h1) h1.textContent = title;
// --- Login options (apply in BOTH phases so login page is correct) ---
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
const disableForm = !!lo.disableFormLogin;
const disableOIDC = !!lo.disableOIDCLogin;
const disableForm = !!lo.disableFormLogin;
const disableOIDC = !!lo.disableOIDCLogin;
const disableBasic = !!lo.disableBasicAuth;
const row = $('#loginForm'); if (row) row.style.display = disableForm ? 'none' : '';
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
const row = $('#loginForm');
if (row) {
if (disableForm) {
row.setAttribute('hidden', '');
row.style.display = ''; // don't leave display:none lying around
} else {
row.removeAttribute('hidden');
row.style.display = '';
}
}
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
if (basic) basic.style.display = disableBasic ? 'none' : '';
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
if (phase === 'final') {
const h1 = document.querySelector('.header-title h1');
if (h1) {
// prevent i18n or legacy from overwriting it
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
if (h1.textContent !== title) h1.textContent = title;
// lock it so late code can't stomp it
if (!h1.__titleLock) {
const mo = new MutationObserver(() => {
if (h1.textContent !== title) h1.textContent = title;
});
mo.observe(h1, { childList: true, characterData: true, subtree: true });
h1.__titleLock = mo;
}
}
}
} catch { }
}
async function readyToReveal() {
// Wait for CSS + fonts so the first revealed frame is fully styled
try { await (window.__CSS_PROMISE__ || Promise.resolve()); } catch { }
try { await document.fonts?.ready; } catch { }
// Give layout one paint to settle
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
}
async function revealAppAndHideOverlay() {
const appRoot = document.getElementById('appRoot');
const overlay = document.getElementById('loadingOverlay');
await readyToReveal();
if (appRoot) appRoot.style.visibility = 'visible';
if (overlay) {
overlay.style.transition = 'opacity .18s ease-out';
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; }, 220);
}
}
async function loadSiteConfig() {
try {
const r = await fetch('/api/siteConfig.php', { credentials: 'include' });
const j = await r.json().catch(() => ({})); applySiteConfig(j);
} catch { applySiteConfig({}); }
const j = await r.json().catch(() => ({}));
window.__FR_SITE_CFG__ = j || {};
// Early pass: title + login options (skip touching <h1> to avoid flicker)
applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' });
return window.__FR_SITE_CFG__;
} catch {
window.__FR_SITE_CFG__ = {};
applySiteConfig({}, { phase: 'early' });
return null;
}
}
async function primeCsrf() {
try {
@@ -631,7 +731,6 @@ function bindDarkMode() {
function forceLoginVisible() {
show($('#main'));
show($('#loginForm'));
hide($('.main-wrapper'));
const hb = $('.header-buttons'); if (hb) hb.style.visibility = 'hidden';
const ov = $('#loadingOverlay'); if (ov) ov.style.display = 'none';
}
@@ -775,8 +874,7 @@ function bindDarkMode() {
window.__FR_FLAGS.booted = true;
ensureToastReady();
// show chrome
const wrap = document.querySelector('.main-wrapper'); if (wrap) { wrap.hidden = false; wrap.classList?.remove('d-none', 'hidden'); wrap.style.display = 'block'; }
const lf = document.getElementById('loginForm'); if (lf) lf.style.display = 'none';
const hb = document.querySelector('.header-buttons'); if (hb) hb.style.visibility = 'visible';
const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'flex';
@@ -791,6 +889,9 @@ function bindDarkMode() {
window.__FR_AUTH_STATE = state;
} catch { }
// authed → heavy boot path
document.body.classList.add('authed');
// 1) i18n (safe)
// i18n: honor saved language first, then apply translations
try {
@@ -806,10 +907,20 @@ function bindDarkMode() {
if (!window.__FR_FLAGS.initialized) {
if (typeof app.loadCsrfToken === 'function') await app.loadCsrfToken();
if (typeof app.initializeApp === 'function') app.initializeApp();
const darkBtn = document.getElementById('darkModeToggle');
if (darkBtn) {
darkBtn.removeAttribute('hidden');
darkBtn.style.setProperty('display', 'inline-flex', 'important'); // beats any CSS
darkBtn.style.visibility = ''; // just in case
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/vendor/material-icons.css?v={{APP_QVER}}';
document.head.appendChild(link);
window.__FR_FLAGS.initialized = true;
// Show "Welcome back, <username>!" only once per tab-session
try {
if (!sessionStorage.getItem('__fr_welcomed')) {
const name = (window.__FR_AUTH_STATE?.username) || localStorage.getItem('username') || '';
@@ -830,7 +941,7 @@ function bindDarkMode() {
auth.applyProxyBypassUI && auth.applyProxyBypassUI();
auth.updateAuthenticatedUI && auth.updateAuthenticatedUI(state);
// ⬇️ bind ALL the admin / change-password buttons once
// bind ALL the admin / change-password buttons once
if (!window.__FR_FLAGS.wired.authInit && typeof auth.initAuth === 'function') {
try { auth.initAuth(); } catch (e) { console.warn('[auth] initAuth failed', e); }
window.__FR_FLAGS.wired.authInit = true;
@@ -879,38 +990,119 @@ function bindDarkMode() {
// ---------- entry (no flicker: decide state BEFORE showing login) ----------
document.addEventListener('DOMContentLoaded', async () => {
if (window.__FR_FLAGS.entryStarted) return;
window.__FR_FLAGS.entryStarted = true;
// Always start clean
document.body.classList.remove('authed');
const overlay = document.getElementById('loadingOverlay');
const wrap = document.querySelector('.main-wrapper'); // app shell
const mainEl = document.getElementById('main'); // contains loginForm
const login = document.getElementById('loginForm');
bindDarkMode();
await loadSiteConfig();
const { authed, setup } = await checkAuth();
if (setup) { await bootSetupWizard(); return; }
if (authed) { await bootHeavy(); return; }
if (setup) {
// Setup wizard runs inside app shell
unhide(wrap);
hideEl(login);
await bootSetupWizard();
await revealAppAndHideOverlay();
// login view
show(document.querySelector('#main'));
show(document.querySelector('#loginForm'));
(document.querySelector('.header-buttons') || {}).style && (document.querySelector('.header-buttons').style.visibility = 'hidden');
const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'none';
return;
}
if (authed) {
// Authenticated path: show app, hide login
document.body.classList.add('authed');
unhide(wrap); // works whether CSS or [hidden] was used
hideEl(login);
await bootHeavy();
await revealAppAndHideOverlay();
requestAnimationFrame(() => {
const pre = document.getElementById('pretheme-css');
if (pre) pre.remove();
});
return;
}
// ---- NOT AUTHED: show only the login view ----
hideEl(wrap); // ensure app shell stays hidden while logged out
unhide(mainEl);
unhide(login);
if (login) login.style.display = '';
// …wire stuff…
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
await revealAppAndHideOverlay();
const hb = document.querySelector('.header-buttons');
if (hb) hb.style.visibility = 'hidden';
// keep app cards inert while logged out (no layout poke)
['uploadCard', 'folderManagementCard'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.style.display = 'none';
el.setAttribute('aria-hidden', 'true');
try { el.inert = true; } catch { }
});
bindLogin();
wireCreateDropdown();
keepCreateDropdownWired();
wireModalEnterDefault();
showLoginTip('Please log in to continue');
await ensureToastReady();
window.showToast('please_log_in_to_continue', 6000);
if (overlay) overlay.style.display = 'none';
}, { once: true });
})();
}, { once: true }); // <— important
// --- Mobile switcher + PWA SW (mobile-only) ---
(() => {
// keep it simple + robust
const qs = new URLSearchParams(location.search);
const hasFrAppHint = qs.get('frapp') === '1';
const isStandalone =
(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ||
(typeof navigator.standalone === 'boolean' && navigator.standalone);
const isCapUA = /\bCapacitor\b/i.test(navigator.userAgent);
const hasCapBridge = !!(window.Capacitor && window.Capacitor.Plugins);
// “mobile-ish”: native mobile UAs OR touch + reasonably narrow viewport (covers iPad-on-Mac UA)
const isMobileish =
/Android|iPhone|iPad|iPod|Mobile|Silk|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints > 1 && Math.min(screen.width, screen.height) <= 900);
// load the switcher only in the mobile app, or mobile standalone PWA, or when explicitly hinted
const shouldLoadSwitcher =
hasCapBridge || isCapUA || (isStandalone && isMobileish) || (hasFrAppHint && isMobileish);
// expose a flag to inspect later
window.FR_APP = !!(hasCapBridge || isCapUA || (isStandalone && isMobileish));
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
if (shouldLoadSwitcher) {
import(`/js/mobile/switcher.js?v=${encodeURIComponent(QVER)}`)
.then(() => {
if (hasFrAppHint && !sessionStorage.getItem('frx_opened_once')) {
sessionStorage.setItem('frx_opened_once', '1');
window.dispatchEvent(new CustomEvent('frx:openSwitcher'));
}
})
.catch(err => console.info('[FileRise] switcher import failed:', err));
}
// SW only for web (https or localhost), never in Capacitor
const onHttps = location.protocol === 'https:' || location.hostname === 'localhost';
if ('serviceWorker' in navigator && onHttps && !hasCapBridge && !isCapUA) {
window.addEventListener('load', () => {
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => {});
});
}
})();

View File

@@ -0,0 +1,365 @@
(function(){
const isCap = !!window.Capacitor || /Capacitor/i.test(navigator.userAgent);
if (!isCap) return;
// NOTE: allow running inside Capacitor (origin "capacitor://localhost")
const Plugins = (window.Capacitor && window.Capacitor.Plugins) || {};
const Pref = Plugins.Preferences ? {
get: ({key}) => Plugins.Preferences.get({key}),
set: ({key,value}) => Plugins.Preferences.set({key,value}),
remove:({key}) => Plugins.Preferences.remove({key})
} : {
get: async ({key}) => ({ value: localStorage.getItem(key) || null }),
set: async ({key,value}) => localStorage.setItem(key, value),
remove: async ({key}) => localStorage.removeItem(key)
};
const Http = (Plugins.Http || Plugins.CapacitorHttp) || null;
const K_INST='fr_instances_v1', K_ACTIVE='fr_active_v1', K_STATUS='fr_status_v1';
const $ = s => document.querySelector(s);
// Safe element builder: attributes only, children as nodes/strings (no innerHTML)
const el = (tag, attrs = {}, children = []) => {
const n = document.createElement(tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
(Array.isArray(children) ? children : [children]).forEach(c => {
if (c == null) return;
n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
return n;
};
// Normalize to http(s), strip creds, collapse trailing slashes
const normalize = (u) => {
if (!u) return '';
let v = u.trim();
if (!/^https?:\/\//i.test(v)) v = 'https://' + v;
try {
const url = new URL(v);
if (!/^https?:$/.test(url.protocol)) return '';
url.username = '';
url.password = '';
url.pathname = url.pathname.replace(/\/+$/,'');
return url.toString();
} catch { return ''; }
};
// Append/overwrite a query param safely on a normalized URL
const withParam = (base, k, v) => {
try {
const u = new URL(normalize(base));
u.searchParams.set(k, v);
return u.toString();
} catch { return ''; }
};
const host = u => {
try { return new URL(normalize(u)).hostname; } catch { return ''; }
};
const originOf = u => {
try { return new URL(normalize(u)).origin; } catch { return ''; }
};
const faviconUrl = u => {
try { const x = new URL(normalize(u)); return x.origin + '/favicon.ico'; } catch { return ''; }
};
const initialsIcon = (hn='FR') => {
const t=(hn||'FR').replace(/^www\./,'').slice(0,2).toUpperCase();
const svg=`<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'>
<rect width='100%' height='100%' rx='12' ry='12' fill='#2196F3'/>
<text x='50%' y='54%' text-anchor='middle' font-family='system-ui,-apple-system,Segoe UI,Roboto,sans-serif'
font-size='28' font-weight='700' fill='#fff'>${t}</text></svg>`;
return 'data:image/svg+xml;utf8,'+encodeURIComponent(svg);
};
async function getStatusCache(){
const raw=(await Pref.get({key:K_STATUS})).value;
try { return raw ? JSON.parse(raw) : {}; } catch { return {}; }
}
async function writeStatus(origin, ok){
const cache=await getStatusCache();
cache[origin]={ ok, ts: Date.now() };
await Pref.set({key:K_STATUS, value:JSON.stringify(cache)});
}
async function verifyFileRise(u, timeout=5000){
if (!u || !Http) return {ok:false};
const base = normalize(u), org = originOf(base);
const tryJson = async (url, validate) => {
try{
const r = await Http.get({ url, connectTimeout:timeout, readTimeout:timeout, headers:{'Accept':'application/json','Cache-Control':'no-cache'} });
if (r && r.data) {
const j = (typeof r.data === 'string') ? JSON.parse(r.data) : r.data;
return !!validate(j);
}
}catch(_){}
return false;
};
if (await tryJson(org + '/siteConfig.json', j => j && (j.appTitle || j.headerTitle || j.auth || j.oidc || j.basicAuth))) return {ok:true, origin:org};
if (await tryJson(org + '/api/ping.php', j => j && (j.ok===true || j.status==='ok' || j.pong || j.app==='FileRise'))) return {ok:true, origin:org};
if (await tryJson(org + '/api/version.php', j => j && (j.version || j.app==='FileRise'))) return {ok:true, origin:org};
try{
const r = await Http.get({ url: org+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
if (typeof r.data === 'string' && /FileRise/i.test(r.data)) return {ok:true, origin:org};
}catch(_){}
return {ok:false, origin:org};
}
async function probeReachable(u, timeout=3000){
try{
const base = new URL(normalize(u)).origin, ico=base+'/favicon.ico';
if (Http){
try{ const r=await Http.get({ url: ico, connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
if (r && typeof r.status==='number' && r.status<500) return true; }catch(e){}
try{ const r2=await Http.get({ url: base+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
if (r2 && typeof r2.status==='number' && r2.status<500) return true; }catch(e){}
return false;
}
return await new Promise(res=>{
const img=new Image(), t=setTimeout(()=>done(false), timeout);
function done(ok){ clearTimeout(t); img.onload=img.onerror=null; res(ok); }
img.onload=()=>done(true); img.onerror=()=>done(false);
img.src = ico + (ico.includes('?')?'&':'?') + '__fr=' + Date.now();
});
}catch{ return false; }
}
async function loadInstances(){
const raw=(await Pref.get({key:K_INST})).value;
try { return raw ? JSON.parse(raw) : []; } catch { return []; }
}
async function saveInstances(list){
await Pref.set({key:K_INST, value:JSON.stringify(list)});
}
async function getActive(){ return (await Pref.get({key:K_ACTIVE})).value }
async function setActive(id){ await Pref.set({key:K_ACTIVE, value:id||''}) }
// ---- Styles (slide-up sheet + disabled buttons + safe-area) ----
if (!$('#frx-mobile-style')) {
const css = `
.frx-fab { position:fixed; right:16px; bottom:calc(env(safe-area-inset-bottom,0px) + 18px); width:52px; height:52px; border-radius:26px;
background: linear-gradient(180deg,#64B5F6,#2196F3 65%,#1976D2); color:#fff; display:grid; place-items:center;
box-shadow:0 10px 22px rgba(33,150,243,.38); z-index:2147483647; cursor:pointer; user-select:none; }
.frx-fab:active { transform: translateY(1px) scale(.98); }
.frx-fab svg { width:26px; height:26px; fill:white }
.frx-scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483645;opacity:0;visibility:hidden;transition:opacity .24s ease}
.frx-scrim.show{opacity:1;visibility:visible}
.frx-sheet{position:fixed;left:0;right:0;bottom:0;background:#0f172a;color:#e5e7eb;
border-top-left-radius:16px;border-top-right-radius:16px;box-shadow:0 -10px 30px rgba(0,0,0,.3);
z-index:2147483646;transform:translateY(100%);opacity:0;visibility:hidden;
transition:transform .28s cubic-bezier(.2,.8,.2,1), opacity .28s ease; will-change:transform}
.frx-sheet.show{transform:translateY(0);opacity:1;visibility:visible}
.frx-sheet .hdr{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,.08)}
.frx-title{display:flex;align-items:center;gap:10px;font-weight:800}
.frx-title img{width:22px;height:22px}
.frx-list{max-height:60vh;overflow:auto;padding:8px 12px}
.frx-chip{border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:12px;margin:8px 4px;background:rgba(255,255,255,.04)}
.frx-chip.active{outline:3px solid rgba(33,150,243,.35); border-color:#2196F3}
.frx-top{display:flex;gap:10px;align-items:center;justify-content:space-between;margin-bottom:10px}
.frx-left{display:flex;gap:10px;align-items:center}
.frx-ico{width:20px;height:20px;border-radius:6px;overflow:hidden;background:#fff;display:grid;place-items:center}
.frx-ico img{width:100%;height:100%;object-fit:cover;display:block}
.frx-name{font-weight:800}
.frx-host{font-size:12px;opacity:.8;margin-top:2px}
.frx-status{display:flex;align-items:center;gap:6px;font-size:12px;opacity:.9}
.frx-dot{width:10px;height:10px;border-radius:50%;}
.frx-dot.on{background:#10B981;box-shadow:0 0 0 3px rgba(16,185,129,.18)}
.frx-dot.off{background:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.18)}
.frx-actions{display:flex;gap:8px;flex-wrap:wrap}
.frx-btn{appearance:none;border:0;border-radius:10px;padding:10px 12px;font-weight:700;cursor:pointer;transition:.15s ease opacity, .15s ease filter}
.frx-btn[disabled]{opacity:.5;cursor:not-allowed;filter:grayscale(20%)}
.frx-primary{background:linear-gradient(180deg,#64B5F6,#2196F3);color:#fff}
.frx-ghost{background:transparent;color:#cbd5e1;border:1px solid rgba(255,255,255,.12)}
.frx-danger{background:transparent;color:#f44336;border:1px solid rgba(244,67,54,.45)}
.frx-row{display:flex;gap:8px;align-items:center}
.frx-field{display:grid;gap:6px;margin:8px 4px}
.frx-input{width:100%;padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,.12);background:transparent;color:inherit}
.frx-footer{display:flex;justify-content:flex-end;gap:8px;padding:10px 12px;border-top:1px solid rgba(255,255,255,.08)}
@media (pointer:coarse) { .frx-fab { width:58px; height:58px; border-radius:29px; } }
`;
document.head.appendChild(el('style',{id:'frx-mobile-style'}, css));
}
// ---- DOM skeleton (no innerHTML) ----
const scrim = el('div',{class:'frx-scrim', id:'frx-scrim'});
const sheet = el('div',{class:'frx-sheet', id:'frx-sheet'});
const hdr = el('div',{class:'hdr'});
const title = el('div',{class:'frx-title'});
const logo = el('img',{src:'/assets/logo.svg', alt:'FileRise'});
// inline handler via property, not attribute
logo.onerror = function(){ this.style.display='none'; };
title.append(logo, el('span',{},'FileRise Switcher'));
const hdrBtns = el('div',{class:'frx-row'},[
el('button',{class:'frx-btn frx-ghost', id:'frx-home'},'Home'),
el('button',{class:'frx-btn frx-ghost', id:'frx-close'},'Close')
]);
hdr.append(title, hdrBtns);
const list = el('div',{class:'frx-list', id:'frx-list'});
const formWrap = el('div',{style:'padding:10px 12px'},[
el('div',{class:'frx-field'},[
el('input',{class:'frx-input', id:'frx-name', placeholder:'Display name (optional)'}),
el('input',{class:'frx-input', id:'frx-url', placeholder:'https://files.example.com'})
])
]);
const footer = el('div',{class:'frx-footer'},[
el('button',{class:'frx-btn frx-ghost', id:'frx-add-cancel'},'Close'),
el('button',{class:'frx-btn frx-primary', id:'frx-add-save'},'+ Add server')
]);
sheet.append(hdr, list, formWrap, footer);
const fab = el('div',{class:'frx-fab', id:'frx-fab', title:'Switch server'},[
el('svg',{viewBox:'0 0 24 24'},[ el('path',{d:'M7 7h10v2H7V7zm0 4h10v2H7v-2zm0 4h10v2H7v-2z'}) ])
]);
document.body.appendChild(scrim);
document.body.appendChild(sheet);
document.body.appendChild(fab);
function show(){ scrim.classList.add('show'); sheet.classList.add('show'); fab.style.display='none'; }
function hide(){ scrim.classList.remove('show'); sheet.classList.remove('show'); fab.style.display='grid'; }
$('#frx-close').addEventListener('click', hide);
$('#frx-add-cancel').addEventListener('click', hide);
$('#frx-home').addEventListener('click', ()=>{ try{ location.href='capacitor://localhost/index.html'; }catch{} });
scrim.addEventListener('click', hide);
document.addEventListener('keydown', e=>{ if(e.key==='Escape') hide(); });
function chipNode(item, isActive){
const hv = host(item.url);
const node = el('div',{class:'frx-chip'+(isActive?' active':''), 'data-id':item.id});
const top = el('div',{class:'frx-top'});
const left = el('div',{class:'frx-left'});
const ico = el('div',{class:'frx-ico'});
const img = new Image();
img.alt=''; img.src=item.favicon||faviconUrl(item.url)||initialsIcon(hv);
img.onerror=()=>{ img.onerror=null; img.src=initialsIcon(hv); };
ico.appendChild(img);
const txt = el('div',{},[
el('div',{class:'frx-name'}, (item.name || hv)),
el('div',{class:'frx-host'}, hv)
]);
left.appendChild(ico);
left.appendChild(txt);
const dot = el('span',{class:'frx-dot', id:`frx-dot-${item.id}`});
const lbl = el('span',{id:`frx-lbl-${item.id}`}, 'Checking…');
const status = el('div',{class:'frx-status'}, [dot, lbl]);
top.appendChild(left);
top.appendChild(status);
const actions = el('div',{class:'frx-actions'});
const bOpen = el('button',{class:'frx-btn frx-primary', 'data-act':'open', disabled:true}, 'Open');
const bRen = el('button',{class:'frx-btn frx-ghost', 'data-act':'rename'}, 'Rename');
const bDel = el('button',{class:'frx-btn frx-danger', 'data-act':'remove'}, 'Remove');
actions.appendChild(bOpen); actions.appendChild(bRen); actions.appendChild(bDel);
node.appendChild(top);
node.appendChild(actions);
return node;
}
async function renderList(){
const listEl=$('#frx-list'); listEl.textContent='';
const list=await loadInstances(); const active=await getActive();
const cache=await getStatusCache();
list.sort((a,b)=>(b.lastUsed||0)-(a.lastUsed||0)).forEach(item=>{
const chip = chipNode(item, item.id===active);
const o = originOf(item.url), cached = cache[o];
const dot = chip.querySelector(`#frx-dot-${item.id}`);
const lbl = chip.querySelector(`#frx-lbl-${item.id}`);
const openBtn = chip.querySelector('[data-act="open"]');
if (cached){
dot.classList.add(cached.ok ? 'on':'off');
lbl.textContent = cached.ok ? 'Online' : 'Offline';
openBtn.disabled = !cached.ok;
} else {
lbl.textContent = 'Unknown';
openBtn.disabled = true;
}
chip.addEventListener('click', async (e)=>{
const act = e.target?.dataset?.act;
if (!act) return;
if (act==='open'){
if (openBtn.disabled) return;
await setActive(item.id);
const dest = withParam(item.url, 'frapp', '1');
if (dest) window.location.replace(dest);
} else if (act==='rename'){
const nn=prompt('New display name:', item.name || host(item.url));
if (nn!=null){
const L=await loadInstances(); const it=L.find(x=>x.id===item.id);
if (it){ it.name=nn.trim().slice(0,120); it.lastUsed=Date.now(); await saveInstances(L); renderList(); }
}
} else if (act==='remove'){
if (!confirm('Remove this server?')) return;
let L=await loadInstances(); L=L.filter(x=>x.id!==item.id); await saveInstances(L);
const a=await getActive(); if (a===item.id) await setActive(L[0]?.id||''); renderList();
}
});
listEl.appendChild(chip);
// Live refresh (best effort)
(async ()=>{
const ok = await probeReachable(item.url, 2500);
const d = document.getElementById(`frx-dot-${item.id}`);
const l = document.getElementById(`frx-lbl-${item.id}`);
const b = chip.querySelector('[data-act="open"]');
if (d && l && b){
d.classList.remove('on','off');
d.classList.add(ok?'on':'off');
l.textContent = ok ? 'Online' : 'Offline';
b.disabled = !ok;
}
const o2 = originOf(item.url); if (o2) writeStatus(o2, ok);
})();
});
}
$('#frx-add-save').addEventListener('click', async ()=>{
const name = $('#frx-name').value.trim();
const url = $('#frx-url').value.trim();
if (!url) { alert('Enter a valid URL'); return; }
// Verify: must be FileRise
const vf = await verifyFileRise(url);
if (!vf.ok) { alert('That address does not look like a FileRise server.'); return; }
let L = await loadInstances();
const h = host(url);
const dupe = L.find(i => host(i.url)===h);
const inst = dupe || { id:'i'+Math.random().toString(36).slice(2)+Date.now().toString(36) };
inst.name = name || inst.name || h;
inst.url = normalize(url);
inst.favicon = faviconUrl(url);
inst.lastUsed = Date.now();
if (!dupe) L.push(inst);
await saveInstances(L);
await setActive(inst.id);
if (vf.origin) await writeStatus(vf.origin, true);
const dest = withParam(inst.url, 'frapp', '1');
if (dest) window.location.replace(dest);
});
fab.addEventListener('click', async ()=>{ await renderList(); show(); });
// Ensure zoom gestures work if the host page tried to disable them
(function ensureZoomable(){
let m = document.querySelector('meta[name=viewport]');
const desired = 'width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=yes, minimum-scale=1, maximum-scale=5';
if (!m){ m = document.createElement('meta'); m.setAttribute('name','viewport'); document.head.appendChild(m); }
const c = m.getAttribute('content') || '';
if (/user-scalable=no|maximum-scale=1/.test(c)) m.setAttribute('content', desired);
})();
})();

View File

@@ -0,0 +1,5 @@
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js?v={{APP_QVER}}').catch(() => {});
});
}

9
public/js/pwa/sw.js Normal file
View File

@@ -0,0 +1,9 @@
// public/js/pwa/sw.js
const SW_VERSION = '{{APP_QVER}}';
const STATIC_CACHE = `fr-static-${SW_VERSION}`;
const STATIC_ASSETS = [
'/', '/index.html',
'/css/styles.css?v={{APP_QVER}}',
'/js/main.js?v={{APP_QVER}}',
'/assets/logo.svg?v={{APP_QVER}}'
];

View File

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

View File

@@ -0,0 +1,14 @@
{
"name": "FileRise",
"short_name": "FileRise",
"start_url": "/?pwa=1",
"scope": "/",
"display": "standalone",
"background_color": "#111111",
"theme_color": "#0b5ed7",
"icons": [
{ "src": "/assets/icons/icon-192.png?v={{APP_QVER}}", "sizes": "192x192", "type": "image/png" },
{ "src": "/assets/icons/icon-512.png?v={{APP_QVER}}", "sizes": "512x512", "type": "image/png" },
{ "src": "/assets/icons/maskable-512.png?v={{APP_QVER}}", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

6
public/sw.js Normal file
View File

@@ -0,0 +1,6 @@
// Root-scoped stub. Keeps the workers scope at “/” level
try {
self.importScripts('/js/pwa/sw.js?v={{APP_QVER}}');
} catch (_) {
// no-op
}

View File

@@ -5,11 +5,11 @@ require_once __DIR__ . '/../../config/config.php';
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);
@@ -17,8 +17,24 @@ class AdminController
echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
}
// Whitelisted public subset only
// ---- 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'] ?? '');
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
$public = [
'header_title' => (string)($config['header_title'] ?? 'FileRise'),
'loginOptions' => [
@@ -34,12 +50,16 @@ class AdminController
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
// never include clientId/clientSecret
],
'onlyoffice' => [
// Public only needs to know if its on; no secrets/origins here.
'enabled' => $effEnabled,
],
];
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
if ($isAdmin) {
// admin-only extras: presence flags + proxy options
// admin-only extras: presence flags + proxy options + ONLYOFFICE effective view
$adminExtra = [
'loginOptions' => array_merge($public['loginOptions'], [
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
@@ -49,12 +69,23 @@ class AdminController
'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);
return;
}
// Non-admins / unauthenticated: only the public subset
header('Cache-Control: no-store');
echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
@@ -221,6 +252,40 @@ class AdminController
}
// —– persist merged config —–
// ---- ONLYOFFICE: merge from payload (unless locked by PHP defines) ----
$ooLockedByPhp = (
defined('ONLYOFFICE_ENABLED') ||
defined('ONLYOFFICE_DOCS_ORIGIN') ||
defined('ONLYOFFICE_JWT_SECRET') ||
defined('ONLYOFFICE_PUBLIC_ORIGIN')
);
if (!$ooLockedByPhp && isset($data['onlyoffice']) && is_array($data['onlyoffice'])) {
$ooExisting = (isset($existing['onlyoffice']) && is_array($existing['onlyoffice']))
? $existing['onlyoffice'] : [];
$oo = $ooExisting;
if (array_key_exists('enabled', $data['onlyoffice'])) {
$oo['enabled'] = filter_var($data['onlyoffice']['enabled'], FILTER_VALIDATE_BOOLEAN);
}
if (isset($data['onlyoffice']['docsOrigin'])) {
$oo['docsOrigin'] = (string)$data['onlyoffice']['docsOrigin'];
}
if (isset($data['onlyoffice']['publicOrigin'])) {
$oo['publicOrigin'] = (string)$data['onlyoffice']['publicOrigin'];
}
// Allow setting/changing the secret when NOT locked by PHP
if (isset($data['onlyoffice']['jwtSecret'])) {
$js = trim((string)$data['onlyoffice']['jwtSecret']);
if ($js !== '') {
$oo['jwtSecret'] = $js; // stored encrypted by AdminModel
}
// If blank, we leave existing secret unchanged (no implicit wipe).
}
$merged['onlyoffice'] = $oo;
}
$result = AdminModel::updateConfig($merged);
if (isset($result['error'])) {
http_response_code(500);

View File

@@ -0,0 +1,135 @@
<?php
// src/controllers/MediaController.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/models/MediaModel.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
class MediaController
{
private function jsonStart(): void {
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json; charset=utf-8');
set_error_handler(function ($severity, $message, $file, $line) {
if (!(error_reporting() & $severity)) return;
throw new ErrorException($message, 0, $severity, $file, $line);
});
}
private function jsonEnd(): void { restore_error_handler(); }
private function out($payload, int $status=200): void {
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
private function readJson(): array {
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
private function requireAuth(): ?string {
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
$this->out(['error'=>'Unauthorized'], 401); return 'no';
}
return null;
}
private function checkCsrf(): ?string {
$headers = function_exists('getallheaders') ? array_change_key_case(getallheaders(), CASE_LOWER) : [];
$received = $headers['x-csrf-token'] ?? '';
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
$this->out(['error'=>'Invalid CSRF token'], 403); return 'no';
}
return null;
}
private function normalizeFolder($f): string {
$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);
}
private function enforceRead(string $folder, string $username): ?string {
$perms = loadUserPermissions($username) ?: [];
return ACL::canRead($username, $perms, $folder) ? null : "Forbidden";
}
/** POST /api/media/updateProgress.php */
public function updateProgress(): void {
$this->jsonStart();
try {
if ($this->requireAuth()) return;
if ($this->checkCsrf()) return;
$u = $_SESSION['username'] ?? '';
$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;
$completed = isset($d['completed']) ? (bool)$d['completed'] : null;
$clear = isset($d['clear']) ? (bool)$d['clear'] : false;
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 ($clear) {
$ok = MediaModel::clearProgress($u, $folder, $file);
$this->out(['success'=>$ok]); return;
}
$row = MediaModel::saveProgress($u, $folder, $file, $seconds, $duration, $completed);
$this->out(['success'=>true, 'state'=>$row]);
} catch (Throwable $e) {
error_log('MediaController::updateProgress: '.$e->getMessage());
$this->out(['error'=>'Internal server error'], 500);
} finally { $this->jsonEnd(); }
}
/** GET /api/media/getProgress.php?folder=…&file=… */
public function getProgress(): void {
$this->jsonStart();
try {
if ($this->requireAuth()) return;
$u = $_SESSION['username'] ?? '';
$folder = $this->normalizeFolder($_GET['folder'] ?? 'root');
$file = (string)($_GET['file'] ?? '');
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; }
$row = MediaModel::getProgress($u, $folder, $file);
$this->out(['state'=>$row]);
} catch (Throwable $e) {
error_log('MediaController::getProgress: '.$e->getMessage());
$this->out(['error'=>'Internal server error'], 500);
} finally { $this->jsonEnd(); }
}
/** GET /api/media/getViewedMap.php?folder=… (optional, for badges) */
public function getViewedMap(): void {
$this->jsonStart();
try {
if ($this->requireAuth()) return;
$u = $_SESSION['username'] ?? '';
$folder = $this->normalizeFolder($_GET['folder'] ?? 'root');
if (!$this->validFolder($folder)) {
$this->out(['error'=>'Invalid folder'], 400); return;
}
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
$map = MediaModel::getFolderMap($u, $folder);
$this->out(['map'=>$map]);
} catch (Throwable $e) {
error_log('MediaController::getViewedMap: '.$e->getMessage());
$this->out(['error'=>'Internal server error'], 500);
} finally { $this->jsonEnd(); }
}
}

View File

@@ -0,0 +1,383 @@
<?php
// src/controllers/OnlyOfficeController.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
class OnlyOfficeController
{
// What FileRise will route to ONLYOFFICE at all (edit *or* view)
private const OO_SUPPORTED_EXTS = [
'doc','docx','odt','rtf','txt',
'xls','xlsx','ods','csv',
'ppt','pptx','odp',
'pdf'
];
// Never editable via OO (well always set edit=false for these)
private const OO_NEVER_EDIT = ['pdf'];
// (Optional) More view-only types you can enable if you like
private const OO_VIEW_ONLY_EXTRAS = [
'djvu','xps','oxps','epub','fb2','pages','hwp','hwpx',
'vsdx','vsdm','vssx','vssm','vstx','vstm'
];
/** Resolve effective secret: constants override adminConfig */
private function effectiveSecret(): string
{
$cfg = AdminModel::getConfig();
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
if (defined('ONLYOFFICE_JWT_SECRET') && ONLYOFFICE_JWT_SECRET !== '') {
return (string)ONLYOFFICE_JWT_SECRET;
}
return (string)($oo['jwtSecret'] ?? '');
}
// --- lightweight logger ------------------------------------------------------
private const OO_LOG_PATH = '/var/www/users/onlyoffice-cb.debug';
private function ooDebug(): bool
{
// Enable verbose logging by either constant or env var
if (defined('ONLYOFFICE_DEBUG') && ONLYOFFICE_DEBUG) return true;
return getenv('ONLYOFFICE_DEBUG') === '1';
}
/**
* @param 'error'|'warn'|'info'|'debug' $level
*/
private function ooLog(string $level, string $msg): void
{
$level = strtolower($level);
$line = '[OO-CB][' . strtoupper($level) . '] ' . $msg;
// Only emit to Apache on errors (keeps logs clean)
if ($level === 'error') {
error_log($line);
}
// If debug mode is on, mirror all levels to a local file
if ($this->ooDebug()) {
@file_put_contents(self::OO_LOG_PATH, '[' . date('c') . '] ' . $line . "\n", FILE_APPEND);
}
}
/** Resolve effective docs origin (http/https root of OO Docs server) */
private function effectiveDocsOrigin(): string
{
$cfg = AdminModel::getConfig();
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
if (defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== '') {
return (string)ONLYOFFICE_DOCS_ORIGIN;
}
if (!empty($oo['docsOrigin'])) return (string)$oo['docsOrigin'];
$env = getenv('ONLYOFFICE_DOCS_ORIGIN');
return $env ? (string)$env : '';
}
/** Resolve effective enabled flag (constants override adminConfig) */
private function effectiveEnabled(): bool
{
$cfg = AdminModel::getConfig();
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
if (defined('ONLYOFFICE_ENABLED')) return (bool)ONLYOFFICE_ENABLED;
return !empty($oo['enabled']);
}
/** Optional explicit public origin; else infer from BASE_URL / request */
private function effectivePublicOrigin(): string
{
$cfg = AdminModel::getConfig();
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
if (defined('ONLYOFFICE_PUBLIC_ORIGIN') && ONLYOFFICE_PUBLIC_ORIGIN !== '') {
return (string)ONLYOFFICE_PUBLIC_ORIGIN;
}
if (!empty($oo['publicOrigin'])) return (string)$oo['publicOrigin'];
// Try BASE_URL if it isn't a placeholder
if (defined('BASE_URL') && strpos((string)BASE_URL, 'yourwebsite') === false) {
$u = parse_url((string)BASE_URL);
if (!empty($u['scheme']) && !empty($u['host'])) {
return $u['scheme'].'://'.$u['host'].(isset($u['port'])?':'.$u['port']:'');
}
}
// Fallback to request (proxy aware)
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO']
?? ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http');
$host = $_SERVER['HTTP_X_FORWARDED_HOST'] ?? ($_SERVER['HTTP_HOST'] ?? 'localhost');
return $proto.'://'.$host;
}
/** base64url encode/decode helpers */
private function b64uDec(string $s)
{
$s = strtr($s, '-_', '+/');
$pad = strlen($s) % 4;
if ($pad) $s .= str_repeat('=', 4 - $pad);
return base64_decode($s, true);
}
private function b64uEnc(string $s): string
{
return rtrim(strtr(base64_encode($s), '+/','-_'), '=');
}
/** GET /api/onlyoffice/status.php */
public function status(): void
{
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$enabled = $this->effectiveEnabled();
$docsOrig = $this->effectiveDocsOrigin();
$secret = $this->effectiveSecret();
// Must have docs origin and secret to actually function
$enabled = $enabled && ($docsOrig !== '') && ($secret !== '');
$exts = self::OO_SUPPORTED_EXTS;
// If you want the extras:
$exts = array_values(array_unique(array_merge($exts, self::OO_VIEW_ONLY_EXTRAS)));
echo json_encode(['enabled' => (bool)$enabled, 'exts' => $exts], JSON_UNESCAPED_SLASHES);
}
/** GET /api/onlyoffice/config.php?folder=...&file=... */
public function config(): void
{
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
@session_start();
$user = $_SESSION['username'] ?? 'anonymous';
$perms = [];
$isAdmin = \ACL::isAdmin($perms);
// Effective toggles
$enabled = $this->effectiveEnabled();
$docsOrigin = rtrim($this->effectiveDocsOrigin(), '/');
$secret = $this->effectiveSecret();
if (!$enabled) { http_response_code(404); echo '{"error":"ONLYOFFICE disabled"}'; return; }
if ($secret === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_JWT_SECRET not configured"}'; return; }
if ($docsOrigin === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_DOCS_ORIGIN not configured"}'; return; }
if (!defined('UPLOAD_DIR')) { http_response_code(500); echo '{"error":"UPLOAD_DIR not defined"}'; return; }
// Inputs
$folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root'));
$file = basename((string)($_GET['file'] ?? ''));
if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; }
// ACL
if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; }
$canEdit = \ACL::canEdit($user, $perms, $folder);
// Path
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
$rel = ($folder === 'root') ? '' : ($folder . '/');
$abs = realpath($base . $rel . $file);
if (!$abs || !is_file($abs)) { http_response_code(404); echo '{"error":"Not found"}'; return; }
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); echo '{"error":"Invalid path"}'; return; }
// Public origin
$publicOrigin = $this->effectivePublicOrigin();
// Signed download
$exp = time() + 10*60;
$data = json_encode(['f'=>$folder,'n'=>$file,'u'=>$user,'adm'=>$isAdmin,'exp'=>$exp], JSON_UNESCAPED_SLASHES);
$sig = hash_hmac('sha256', $data, $secret, true);
$tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig);
$fileUrl = $publicOrigin . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok);
// Callback
$cbExp = time() + 10*60;
$cbSig = hash_hmac('sha256', $folder.'|'.$file.'|'.$cbExp, $secret);
$callbackUrl = $publicOrigin . '/api/onlyoffice/callback.php'
. '?folder=' . rawurlencode($folder)
. '&file=' . rawurlencode($file)
. '&exp=' . $cbExp
. '&sig=' . $cbSig;
// Doc type & key
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx');
$docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell'
: (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word');
$key = substr(sha1($abs . '|' . (string)filemtime($abs)), 0, 20);
$docsApiJs = $docsOrigin . '/web-apps/apps/api/documents/api.js';
$cfgOut = [
'document' => [
'fileType' => $ext,
'key' => $key,
'title' => $file,
'url' => $fileUrl,
'permissions' => [
'download' => true,
'print' => true,
'edit' => $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true),
],
],
'documentType' => $docType,
'editorConfig' => [
'callbackUrl' => $callbackUrl,
'user' => ['id'=>$user, 'name'=>$user],
'lang' => 'en',
],
'type' => 'desktop',
];
// JWT sign cfg
$h = $this->b64uEnc(json_encode(['alg'=>'HS256','typ'=>'JWT']));
$p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES));
$s = $this->b64uEnc(hash_hmac('sha256', "$h.$p", $secret, true));
$cfgOut['token'] = "$h.$p.$s";
$cfgOut['docs_api_js'] = $docsApiJs;
echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES);
}
/** POST /api/onlyoffice/callback.php?folder=...&file=...&exp=...&sig=... */
public function callback(): void
{
header('Content-Type: application/json; charset=utf-8');
if (isset($_GET['ping'])) { echo '{"error":0}'; return; }
$secret = $this->effectiveSecret();
if ($secret === '') { http_response_code(500); $this->ooLog('error', 'missing secret'); echo '{"error":6}'; return; }
$folderRaw = (string)($_GET['folder'] ?? 'root');
$fileRaw = (string)($_GET['file'] ?? '');
$exp = (int)($_GET['exp'] ?? 0);
$sig = (string)($_GET['sig'] ?? '');
$calc = hash_hmac('sha256', "$folderRaw|$fileRaw|$exp", $secret);
// Debug-only preflight (no secrets; show short sigs)
if ($this->ooDebug()) {
$this->ooLog('debug', sprintf(
"PRE f='%s' n='%s' exp=%d sig[8]=%s calc[8]=%s",
$folderRaw, $fileRaw, $exp, substr($sig, 0, 8), substr($calc, 0, 8)
));
}
$folder = \ACL::normalizeFolder($folderRaw);
$file = basename($fileRaw);
if (!$exp || time() > $exp) { $this->ooLog('error', "expired exp for $folder/$file"); echo '{"error":6}'; return; }
if (!hash_equals($calc, $sig)) { $this->ooLog('error', "sig mismatch for $folder/$file"); echo '{"error":6}'; return; }
$raw = file_get_contents('php://input') ?: '';
if ($this->ooDebug()) {
$this->ooLog('debug', 'BODY len=' . strlen($raw));
}
$body = json_decode($raw, true) ?: [];
$status = (int)($body['status'] ?? 0);
$actor = (string)($body['actions'][0]['userid'] ?? '');
$actorIsAdmin = (defined('DEFAULT_ADMIN_USER') && $actor !== '' && strcasecmp($actor, (string)DEFAULT_ADMIN_USER) === 0)
|| (strcasecmp($actor, 'admin') === 0);
$perms = $actorIsAdmin ? ['admin'=>true] : [];
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
$rel = ($folder === 'root') ? '' : ($folder . '/');
$dir = realpath($base . $rel) ?: ($base . $rel);
if (strpos($dir, realpath($base)) !== 0) { $this->ooLog('error', 'path escape'); echo '{"error":6}'; return; }
// Save-on statuses: 2/6/7
if (in_array($status, [2,6,7], true)) {
if (!$actor || !\ACL::canEdit($actor, $perms, $folder)) {
$this->ooLog('error', "ACL deny edit: actor='$actor' folder='$folder'");
echo '{"error":6}'; return;
}
$saveUrl = (string)($body['url'] ?? '');
if ($saveUrl === '') { $this->ooLog('error', "no url for status=$status"); echo '{"error":6}'; return; }
// fetch saved file
$data = null; $curlErr=''; $httpCode=0;
if (function_exists('curl_init')) {
$ch = curl_init($saveUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 45,
CURLOPT_HTTPHEADER => ['Accept: */*','User-Agent: FileRise-ONLYOFFICE-Callback'],
]);
$data = curl_exec($ch);
if ($data === false) $curlErr = curl_error($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($data === false || $httpCode >= 400) {
$this->ooLog('error', "curl get failed ($httpCode) url=$saveUrl err=" . ($curlErr ?: 'n/a'));
$data = null;
}
}
if ($data === null) {
$ctx = stream_context_create(['http'=>['method'=>'GET','timeout'=>45,'header'=>"Accept: */*\r\n"]]);
$data = @file_get_contents($saveUrl, false, $ctx);
if ($data === false) { $this->ooLog('error', "stream get failed url=$saveUrl"); echo '{"error":6}'; return; }
}
if (!is_dir($dir)) { @mkdir($dir, 0775, true); }
$dest = rtrim($dir, "/\\") . DIRECTORY_SEPARATOR . $file;
if (@file_put_contents($dest, $data) === false) { $this->ooLog('error', "write failed: $dest"); echo '{"error":6}'; return; }
@touch($dest);
// Success: debug only
if ($this->ooDebug()) {
$this->ooLog('debug', "saved OK by '$actor' → $dest (" . strlen($data) . " bytes, status=$status)");
}
echo '{"error":0}'; return;
}
// Non-saving statuses: debug only
if ($this->ooDebug()) {
$this->ooLog('debug', "status=$status ack for $folder/$file by '$actor'");
}
echo '{"error":0}';
}
/** GET /api/onlyoffice/signed-download.php?tok=... */
public function signedDownload(): void
{
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
$secret = $this->effectiveSecret();
if ($secret === '') { http_response_code(403); return; }
$tok = $_GET['tok'] ?? '';
if (!$tok || strpos($tok, '.') === false) { http_response_code(400); return; }
[$b64data, $b64sig] = explode('.', $tok, 2);
$data = $this->b64uDec($b64data);
$sig = $this->b64uDec($b64sig);
if ($data === false || $sig === false) { http_response_code(400); return; }
$calc = hash_hmac('sha256', $data, $secret, true);
if (!hash_equals($calc, $sig)) { http_response_code(403); return; }
$payload = json_decode($data, true);
if (!$payload || !isset($payload['f'],$payload['n'],$payload['exp'])) { http_response_code(400); return; }
if (time() > (int)$payload['exp']) { http_response_code(403); return; }
$folder = trim(str_replace('\\','/',$payload['f']),"/ \t\r\n");
if ($folder === '' || $folder === 'root') $folder = 'root';
$file = basename((string)$payload['n']);
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
$rel = ($folder === 'root') ? '' : ($folder . '/');
$abs = realpath($base . $rel . $file);
if (!$abs || !is_file($abs)) { http_response_code(404); return; }
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); return; }
$mime = mime_content_type($abs) ?: 'application/octet-stream';
header('Content-Type: '.$mime);
header('Content-Length: '.filesize($abs));
header('Content-Disposition: inline; filename="' . rawurlencode($file) . '"');
readfile($abs);
}
}

View File

@@ -62,27 +62,59 @@ class AdminModel
return (int)$val;
}
public static function buildPublicSubset(array $config): array
/** Allow only http(s) URLs; return '' for invalid input. */
private static function sanitizeHttpUrl($url): string
{
return [
'header_title' => $config['header_title'] ?? 'FileRise',
'loginOptions' => [
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
// do NOT include authBypass/authHeaderName here — admin-only
],
'globalOtpauthUrl' => $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
],
];
$url = trim((string)$url);
if ($url === '') return '';
$valid = filter_var($url, FILTER_VALIDATE_URL);
if (!$valid) return '';
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?: '');
return ($scheme === 'http' || $scheme === 'https') ? $url : '';
}
public static function buildPublicSubset(array $config): array
{
$public = [
'header_title' => $config['header_title'] ?? 'FileRise',
'loginOptions' => [
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
],
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
'oidc' => [
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
],
];
// NEW: include ONLYOFFICE minimal public flag
$ooEnabled = null;
if (isset($config['onlyoffice']['enabled'])) {
$ooEnabled = (bool)$config['onlyoffice']['enabled'];
} elseif (defined('ONLYOFFICE_ENABLED')) {
$ooEnabled = (bool)ONLYOFFICE_ENABLED;
}
if ($ooEnabled !== null) {
$public['onlyoffice'] = ['enabled' => $ooEnabled];
}
$locked = defined('ONLYOFFICE_ENABLED') || defined('ONLYOFFICE_JWT_SECRET')
|| defined('ONLYOFFICE_DOCS_ORIGIN') || defined('ONLYOFFICE_PUBLIC_ORIGIN');
if ($locked) {
$ooEnabled = defined('ONLYOFFICE_ENABLED') ? (bool)ONLYOFFICE_ENABLED : false;
} else {
$ooEnabled = isset($config['onlyoffice']['enabled']) ? (bool)$config['onlyoffice']['enabled'] : false;
}
$public['onlyoffice'] = ['enabled' => $ooEnabled];
return $public;
}
/** Write USERS_DIR/siteConfig.json atomically (unencrypted). */
public static function writeSiteConfig(array $publicSubset): array
{
@@ -173,6 +205,28 @@ class AdminModel
$configUpdate['loginOptions']['authHeaderName'] = trim($configUpdate['loginOptions']['authHeaderName']);
}
// ---- ONLYOFFICE (persist, sanitize; keep secret unless explicitly replaced) ----
if (isset($configUpdate['onlyoffice']) && is_array($configUpdate['onlyoffice'])) {
$oo = $configUpdate['onlyoffice'];
$norm = [
'enabled' => (bool)($oo['enabled'] ?? false),
'docsOrigin' => self::sanitizeHttpUrl($oo['docsOrigin'] ?? ''),
'publicOrigin' => self::sanitizeHttpUrl($oo['publicOrigin'] ?? ''),
];
// Only accept a new secret if provided (non-empty). We do NOT clear on empty.
if (array_key_exists('jwtSecret', $oo)) {
$js = trim((string)$oo['jwtSecret']);
if ($js !== '') {
if (strlen($js) > 1024) $js = substr($js, 0, 1024);
$norm['jwtSecret'] = $js; // will be encrypted with encryptData()
}
}
$configUpdate['onlyoffice'] = $norm;
}
// Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
if ($plainTextConfig === false) {
@@ -301,6 +355,19 @@ class AdminModel
$config['sharedMaxUploadSize'] = (int)min((int)$config['sharedMaxUploadSize'], $maxBytes);
}
// ---- Ensure ONLYOFFICE structure exists, sanitize values ----
if (!isset($config['onlyoffice']) || !is_array($config['onlyoffice'])) {
$config['onlyoffice'] = [
'enabled' => false,
'docsOrigin' => '',
'publicOrigin' => '',
];
} else {
$config['onlyoffice']['enabled'] = (bool)($config['onlyoffice']['enabled'] ?? false);
$config['onlyoffice']['docsOrigin'] = self::sanitizeHttpUrl($config['onlyoffice']['docsOrigin'] ?? '');
$config['onlyoffice']['publicOrigin'] = self::sanitizeHttpUrl($config['onlyoffice']['publicOrigin'] ?? '');
}
return $config;
}
@@ -320,7 +387,12 @@ class AdminModel
],
'globalOtpauthUrl' => "",
'enableWebDAV' => false,
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE))
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE)),
'onlyoffice' => [
'enabled' => false,
'docsOrigin' => '',
'publicOrigin' => '',
],
];
}
}
}

94
src/models/MediaModel.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
// src/models/MediaModel.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
class MediaModel
{
private static function baseDir(): string {
$dir = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . 'user_state';
if (!is_dir($dir)) @mkdir($dir, 0775, true);
return $dir . DIRECTORY_SEPARATOR;
}
private static function filePathFor(string $username): string {
// case-insensitive username file
$safe = strtolower(preg_replace('/[^a-z0-9_\-\.]/i', '_', $username));
return self::baseDir() . $safe . '_media.json';
}
private static function loadState(string $username): array {
$path = self::filePathFor($username);
if (!file_exists($path)) return ["version"=>1, "items"=>[]];
$json = file_get_contents($path);
$data = json_decode($json, true);
return (is_array($data) && isset($data['items'])) ? $data : ["version"=>1, "items"=>[]];
}
private static function saveState(string $username, array $state): bool {
$path = self::filePathFor($username);
$tmp = $path . '.tmp';
$ok = file_put_contents($tmp, json_encode($state, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOCK_EX);
if ($ok === false) return false;
return @rename($tmp, $path);
}
/** Save/merge a single file progress record. */
public static function saveProgress(string $username, string $folder, string $file, float $seconds, ?float $duration, ?bool $completed): array {
$folderKey = ($folder === '' || strtolower($folder)==='root') ? 'root' : $folder;
$nowIso = date('c');
$state = self::loadState($username);
if (!isset($state['items'][$folderKey])) $state['items'][$folderKey] = [];
if (!isset($state['items'][$folderKey][$file])) {
$state['items'][$folderKey][$file] = [
"seconds" => 0,
"duration" => $duration ?? 0,
"completed" => false,
"updatedAt" => $nowIso
];
}
$row =& $state['items'][$folderKey][$file];
if ($duration !== null && $duration > 0) $row['duration'] = $duration;
if ($seconds >= 0) $row['seconds'] = $seconds;
if ($completed !== null) $row['completed'] = (bool)$completed;
// auto-complete if were basically done
if (!$row['completed'] && $row['duration'] > 0 && $row['seconds'] >= max(0, $row['duration'] * 0.95)) {
$row['completed'] = true;
}
$row['updatedAt'] = $nowIso;
self::saveState($username, $state);
return $row;
}
/** Get a single file progress record. */
public static function getProgress(string $username, string $folder, string $file): array {
$folderKey = ($folder === '' || strtolower($folder)==='root') ? 'root' : $folder;
$state = self::loadState($username);
$row = $state['items'][$folderKey][$file] ?? null;
return is_array($row) ? $row : ["seconds"=>0,"duration"=>0,"completed"=>false,"updatedAt"=>null];
}
/** Folder map: filename => {seconds,duration,completed,updatedAt} */
public static function getFolderMap(string $username, string $folder): array {
$folderKey = ($folder === '' || strtolower($folder)==='root') ? 'root' : $folder;
$state = self::loadState($username);
$items = $state['items'][$folderKey] ?? [];
return is_array($items) ? $items : [];
}
/** Clear one files progress (e.g., “mark unviewed”). */
public static function clearProgress(string $username, string $folder, string $file): bool {
$folderKey = ($folder === '' || strtolower($folder)==='root') ? 'root' : $folder;
$state = self::loadState($username);
if (isset($state['items'][$folderKey][$file])) {
unset($state['items'][$folderKey][$file]);
return self::saveState($username, $state);
}
return true;
}
}