Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ffe9b3ffc | ||
|
|
abd3dad5a5 | ||
|
|
4c849b1dc3 | ||
|
|
7cc314179f | ||
|
|
9ddb633cca | ||
|
|
448e246689 | ||
|
|
dc7797e50d | ||
|
|
913d370ef2 | ||
|
|
488b5cb532 | ||
|
|
15b5aa6d8d | ||
|
|
8f03cc7456 | ||
|
|
c9a99506d7 | ||
|
|
04ec0a0830 | ||
|
|
429cd0314a | ||
|
|
ba29cc4822 | ||
|
|
e2cd304158 | ||
|
|
ca8788a694 | ||
|
|
dc45fed886 | ||
|
|
a9fe342175 | ||
|
|
7669f5a10b | ||
|
|
34a4e06a23 | ||
|
|
d00faf5fe7 |
120
.github/workflows/release-on-version.yml
vendored
@@ -3,12 +3,20 @@ name: Release on version.js update
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"] # keep as-is; change to ["master","main"] if you use main too
|
||||
branches: ["master"]
|
||||
paths:
|
||||
- public/js/version.js
|
||||
workflow_run:
|
||||
workflows: ["Bump version and sync Changelog to Docker Repo"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Ref (branch or SHA) to build from (default: origin/master)"
|
||||
required: false
|
||||
version:
|
||||
description: "Explicit version tag to release (e.g., v1.8.6). If empty, auto-detect."
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -17,8 +25,8 @@ jobs:
|
||||
delay:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delay 2 minutes
|
||||
run: sleep 120
|
||||
- name: Delay 10 minutes
|
||||
run: sleep 600
|
||||
|
||||
release:
|
||||
needs: delay
|
||||
@@ -27,37 +35,94 @@ jobs:
|
||||
# Guard: Only run on trusted workflow_run events (pushes from this repo)
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
||||
|
||||
# Use run_id for a stable, unique key
|
||||
concurrency:
|
||||
# Ensure concurrency key follows the actual source ref
|
||||
group: release-${{ github.event_name }}-${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
group: release-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Resolve correct ref
|
||||
- name: Checkout (fetch all)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure tags + master available
|
||||
shell: bash
|
||||
run: |
|
||||
git fetch --tags --force --prune --quiet
|
||||
git fetch origin master --quiet
|
||||
|
||||
- name: Resolve source ref + (maybe) version
|
||||
id: pickref
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_run" ]; then
|
||||
echo "ref=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ref=${{ github.sha }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "Using ref: $(cat $GITHUB_OUTPUT)"
|
||||
set -euo pipefail
|
||||
|
||||
- name: Checkout
|
||||
# Defaults
|
||||
REF=""
|
||||
VER=""
|
||||
SRC=""
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
# manual run
|
||||
REF_IN="${{ github.event.inputs.ref }}"
|
||||
VER_IN="${{ github.event.inputs.version }}"
|
||||
if [[ -n "$REF_IN" ]]; then
|
||||
# Try branch/sha; fetch branch if needed
|
||||
git fetch origin "$REF_IN" --quiet || true
|
||||
if REF_SHA="$(git rev-parse --verify --quiet "$REF_IN")"; then
|
||||
REF="$REF_SHA"
|
||||
else
|
||||
echo "Provided ref '$REF_IN' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
REF="$(git rev-parse origin/master)"
|
||||
fi
|
||||
if [[ -n "$VER_IN" ]]; then
|
||||
VER="$VER_IN"
|
||||
SRC="manual-version"
|
||||
fi
|
||||
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
REF="${{ github.event.workflow_run.head_sha }}"
|
||||
else
|
||||
REF="${{ github.sha }}"
|
||||
fi
|
||||
|
||||
# If no explicit version, try to find the latest bot bump reachable from REF
|
||||
if [[ -z "$VER" ]]; then
|
||||
# Search recent history reachable from REF
|
||||
BOT_SHA="$(git log "$REF" -n 200 --author='github-actions[bot]' --grep='set APP_VERSION to v' --pretty=%H | head -n1 || true)"
|
||||
if [[ -n "$BOT_SHA" ]]; then
|
||||
SUBJ="$(git log -n1 --pretty=%s "$BOT_SHA")"
|
||||
BOT_VER="$(sed -n 's/.*set APP_VERSION to \(v[^ ]*\).*/\1/p' <<<"${SUBJ}")"
|
||||
if [[ -n "$BOT_VER" ]]; then
|
||||
VER="$BOT_VER"
|
||||
REF="$BOT_SHA" # build/tag from the bump commit
|
||||
SRC="bot-commit"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output
|
||||
REF_SHA="$(git rev-parse "$REF")"
|
||||
echo "ref=$REF_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "source=${SRC:-event-ref}" >> "$GITHUB_OUTPUT"
|
||||
echo "preversion=${VER}" >> "$GITHUB_OUTPUT"
|
||||
echo "Using source=${SRC:-event-ref} ref=$REF_SHA"
|
||||
if [[ -n "$VER" ]]; then echo "Pre-resolved version=$VER"; fi
|
||||
|
||||
- name: Checkout chosen ref
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.pickref.outputs.ref }}
|
||||
|
||||
- name: Ensure tags available
|
||||
run: git fetch --tags --force --prune --quiet
|
||||
|
||||
# Guard: refuse if the ref isn’t contained in master
|
||||
- name: Assert ref is on master
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -66,26 +131,35 @@ jobs:
|
||||
git fetch origin master --quiet
|
||||
if ! git merge-base --is-ancestor "$REF" origin/master; then
|
||||
echo "Ref $REF is not on master; refusing to release."
|
||||
exit 78 # neutral exit
|
||||
exit 78
|
||||
fi
|
||||
|
||||
- name: Debug version.js origin
|
||||
- name: Debug version.js provenance
|
||||
shell: bash
|
||||
run: |
|
||||
echo "version.js at commit: $(git log -n1 --pretty=%h -- public/js/version.js)"
|
||||
echo "version.js last-change commit: $(git log -n1 --pretty='%h %s' -- public/js/version.js || echo 'none')"
|
||||
sed -n '1,20p' public/js/version.js || true
|
||||
|
||||
- name: Read version from version.js
|
||||
- name: Determine version
|
||||
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/")
|
||||
# Prefer pre-resolved version (manual input or bot commit)
|
||||
if [[ -n "${{ steps.pickref.outputs.preversion }}" ]]; then
|
||||
VER="${{ steps.pickref.outputs.preversion }}"
|
||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Parsed version (pre-resolved): $VER"
|
||||
exit 0
|
||||
fi
|
||||
# Fallback to version.js
|
||||
VER="$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")"
|
||||
if [[ -z "$VER" ]]; then
|
||||
echo "Could not parse APP_VERSION from version.js" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Parsed version: $VER"
|
||||
echo "Parsed version (file): $VER"
|
||||
|
||||
- name: Skip if tag already exists
|
||||
id: tagcheck
|
||||
|
||||
211
CHANGELOG.md
@@ -1,5 +1,216 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 11/9/2025 (v1.9.0)
|
||||
|
||||
release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening
|
||||
|
||||
feat(ui): modern folder tree
|
||||
|
||||
- New crisp folder SVG with clear paper insert; unified yellow/orange palette for light & dark
|
||||
- Proper ARIA tree semantics (role=treeitem, aria-expanded), cleaner chevrons, better alignment
|
||||
- Breadcrumb tweaks (› separators), hover/selected polish
|
||||
- Prime icons locally, then confirm via counts for accurate “empty vs non-empty”
|
||||
|
||||
feat(api): add /api/folder/isEmpty.php via controller/model
|
||||
|
||||
- public/api/folder/isEmpty.php delegates to FolderController::stats()
|
||||
- FolderModel::countVisible() enforces ACL, path safety, and short-circuits after first entry
|
||||
- Releases PHP session lock early to avoid parallel-request pileups
|
||||
|
||||
perf: cap concurrent “isEmpty” requests + timeouts
|
||||
|
||||
- Small concurrency limiter + fetch timeouts
|
||||
- In-memory result & inflight caches for fewer network hits
|
||||
|
||||
fix(state): preserve user expand/collapse choices
|
||||
|
||||
- Respect saved folderTreeState; don’t auto-expand unopened nodes
|
||||
- Only show ancestors for visibility when navigating (no unwanted persists)
|
||||
|
||||
security: tighten .htaccess while enabling WebDAV
|
||||
|
||||
- Deny direct PHP except /api/*.php, /api.php, and /webdav.php
|
||||
- AcceptPathInfo On; keep path-aware dotfile denial
|
||||
|
||||
refactor: move count logic to model; thin controller action
|
||||
|
||||
chore(css): add unified “folder tree” block with variables (sizes, gaps, colors)
|
||||
|
||||
Files touched: FolderModel.php, FolderController.php, public/js/folderManager.js, public/css/styles.css, public/api/folder/isEmpty.php (new), public/.htaccess
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/8/2025 (v1.8.13)
|
||||
|
||||
release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync
|
||||
|
||||
- dnd: fix disappearing/overlapping cards when moving between sidebar/top; return to origin on failed drop
|
||||
- layout: placeCardInZone now live-updates top layout, sidebar visibility, and toggle icon
|
||||
- toggle/collapse: move ALL cards to header on collapse, restore saved layout on expand; keep icon state synced; add body.sidebar-hidden for proper file list expansion; emit `zones:collapsed-changed`
|
||||
- header dock: show dock whenever icons exist (and on collapse); hide when empty
|
||||
- responsive: enforceResponsiveZones also updates toggle icon; stash/restore behavior unchanged
|
||||
- sidebar: hard-lock width to 350px (CSS) and remove runtime 280px minWidth; add placeholder when empty to make dropping back easy
|
||||
- CSS: right-align header dock buttons, centered “Drop Zone” label, sensible min-height; dark-mode safe
|
||||
- refactor: small renames/ordering; remove redundant z-index on toggle; minor formatting
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/8/2025 (v1.8.12)
|
||||
|
||||
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons
|
||||
|
||||
- auth (public/js/main.js)
|
||||
- Robust login options: tolerate key variants (disableFormLogin/disable_form_login, etc.).
|
||||
- Correctly show/hide wrapper + individual methods (form/OIDC/basic).
|
||||
- Auto-SSO when OIDC is the only enabled method; add opt-out with `?noauto=1`.
|
||||
- Minor cleanup (SW register catch spacing).
|
||||
|
||||
- drag & drop (public/js/dragAndDrop.js)
|
||||
- Reworked zones model: Sidebar / Top (left/right) / Header (icon+modal).
|
||||
- Persist user layout with `userZonesSnapshot.v2` and responsive stash for small screens.
|
||||
- Live UI sync: toggle icon (`material-icons`) updates immediately after moves.
|
||||
- Smarter small-screen behavior: lift sidebar cards ephemerally; restore only what belonged to sidebar.
|
||||
- Cleaner header icon modal plumbing; remove legacy/dead code.
|
||||
|
||||
- styles (public/css/styles.css)
|
||||
- Header drop zone fills remaining space and right-aligns its icons.
|
||||
|
||||
UX:
|
||||
|
||||
- OIDC button reliably appears when form/basic are disabled.
|
||||
- If OIDC is the sole method, users are taken straight to the provider (unless `?noauto=1`).
|
||||
- Header icons sit with the other header actions (right-aligned), and the toggle icon reflects layout changes instantly.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/8/2025 (v1.8.11)
|
||||
|
||||
release(v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client
|
||||
|
||||
- Force PKCE via setCodeChallengeMethod('S256') so Authelia’s public-client policy is satisfied.
|
||||
- Convert empty OIDC client secret to null to correctly signal a public client.
|
||||
- Optional commented hook to switch token endpoint auth to client_secret_post if desired.
|
||||
- OIDC_TOKEN_ENDPOINT_AUTH_METHOD added to config.php
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/8/2025 (v1.8.10)
|
||||
|
||||
release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul
|
||||
|
||||
UI/UX — Media modal
|
||||
|
||||
- Add fixed top bar to avoid filename/controls overlapping native media chrome; keep hover-on-stage look.
|
||||
- Show a Material icon by file type next to the filename (image/video/pdf/code/arch/txt, with fallback).
|
||||
- Restore “X” behavior and make hover theme-aware (red pill + white ‘X’ in light, red pill + black ‘X’ in dark).
|
||||
|
||||
Video/Image controls
|
||||
|
||||
- Top-right action icons use theme-aware styles and align with the filename row.
|
||||
- Prev/Next paddles remain high-contrast and vertically centered within the stage.
|
||||
|
||||
Progress badges (list & modal)
|
||||
|
||||
- Standardize “in-progress” to darker orange (#ea580c) for better contrast in light/dark; update CSS and list badge rendering.
|
||||
|
||||
Drag & drop
|
||||
|
||||
- Support multi-select drags with a clean JSON payload + text fallback; nicer drag ghost.
|
||||
- More resilient drops: accept data-dest-folder, safer JSON parse, early guards, and better toasts.
|
||||
- POST move now sends Accept header, uses global CSRF, and refreshes the active view on success.
|
||||
|
||||
Editor & ONLYOFFICE
|
||||
|
||||
- Full-screen OO modal with preconnect, optional hidden warm-up to reduce first-open latency, and live theme sync.
|
||||
- CodeMirror path: fix theme/mode setters (use `cm`) and tighten dynamic mode loading.
|
||||
|
||||
Assets & polish
|
||||
|
||||
- Swap in full favicon stack (SVG + PNG 512/32/16 + ICO) and set theme-color; cache-busted via `{{APP_QVER}}`.
|
||||
- Refresh `logo.svg` (accessibility, cleaner handles/gradients).
|
||||
|
||||
Also added: refreshed resource images and new logo sizes (logo-16, logo-32, logo-64, etc.) for crisper favicons and embeds.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/7/2025 (v1.8.9)
|
||||
|
||||
release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64)
|
||||
|
||||
- adminPanel.js:
|
||||
- Masked inputs without a saved value now start with data-replace="1".
|
||||
- handleSave() now sends oidc.clientId / oidc.clientSecret on first save (no longer requires clicking “Replace” first).
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/7/2025 (v1.8.8)
|
||||
|
||||
release(v1.8.8): background ZIP jobs w/ tokenized download + in‑modal progress bar; robust finalize; janitor cleanup — closes #60
|
||||
|
||||
**Summary**
|
||||
This release moves ZIP creation off the request thread into a **background worker** and switches the client to a **queue > poll > tokenized GET** download flow. It fixes large multi‑GB ZIP failures caused by request timeouts or cross‑device renames, and provides a resilient in‑modal progress experience. It also adds a 6‑hour janitor for temporary tokens/logs.
|
||||
|
||||
**Backend** changes:
|
||||
|
||||
- Add **zip status** endpoint that returns progress and readiness, and **tokenized download** endpoint for one‑shot downloads.
|
||||
- Update `FileController::downloadZip()` to enqueue a job and return `{ token, statusUrl, downloadUrl }` instead of streaming a blob in the POST response.
|
||||
- Implement `spawnZipWorker()` to find a working PHP CLI, set `TMPDIR` on the same filesystem as the final ZIP, spawn with `nohup`, and persist PID/log metadata for diagnostics.
|
||||
- Serve finished ZIPs via `downloadZipFile()` with strict token/user checks and streaming headers; unlink the ZIP after successful read.
|
||||
|
||||
New **Worker**:
|
||||
|
||||
- New `src/cli/zip_worker.php` builds the archive in the background.
|
||||
- Writes progress fields (`pct`, `filesDone`, `filesTotal`, `bytesDone`, `bytesTotal`, `current`, `phase`, `startedAt`, `finalizeAt`) to the per‑token JSON.
|
||||
- During **finalizing**, publishes `selectedFiles`/`selectedBytes` and clears incremental counters to avoid the confusing “N/N files” display before `close()` returns.
|
||||
- Adds a **janitor**: purge `.tokens/*.json` and `.logs/WORKER-*.log` older than **6 hours** on each run.
|
||||
|
||||
New **API/Status Payload**:
|
||||
|
||||
- `zipStatus()` exposes `ready` (derived from `status=done` + existing `zipPath`), and includes `startedAt`/`finalizeAt` for UI timers.
|
||||
- Returns a prebuilt `downloadUrl` for a direct handoff once the ZIP is ready.
|
||||
|
||||
**Frontend (UX)** changes:
|
||||
|
||||
- Replace blob POST download with **enqueue → poll → tokenized GET** flow.
|
||||
- Native `<progress>` bar now renders **inside the modal** (no overflow/jitter).
|
||||
- Shows determinate **0–98%** during enumeration, then **locks at 100%** with **“Finalizing… mm:ss — N files, ~Size”** until the download starts.
|
||||
- Modal closes just before download; UI resets for the next operation.
|
||||
|
||||
Added **CSS**:
|
||||
|
||||
- Ensure the progress modal has a minimum height and hidden overflow; ellipsize the status line to prevent scrollbars.
|
||||
|
||||
**Why this closes #60**?
|
||||
|
||||
- ZIP creation no longer depends on the request lifetime (avoids proxy/Apache timeouts).
|
||||
- Temporary files and final ZIP are created on the **same filesystem** (prevents “rename temp file failed” during `ZipArchive::close()`).
|
||||
- Users get continuous, truthful feedback for large multi‑GB archives.
|
||||
|
||||
Additional **Notes**
|
||||
|
||||
- Download tokens are **one‑shot** and are deleted after the GET completes.
|
||||
- Temporary artifacts (`META_DIR/ziptmp/.tokens`, `.logs`, and old ZIPs) are cleaned up automatically (≥6h).
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/5/2025 (v1.8.7)
|
||||
|
||||
release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives
|
||||
|
||||
- FileController::downloadZip
|
||||
- Remove _jsonStart/_jsonEnd and JSON wrappers; send a pure binary ZIP
|
||||
- Close session locks, disable gzip/output buffering, set Content-Length when known
|
||||
- Stream in 1MiB chunks; proper HTTP codes/messages on errors
|
||||
- Unlink the temp ZIP after successful send
|
||||
- Preserves all auth/ACL/ownership checks
|
||||
|
||||
- FileModel::createZipArchive
|
||||
- Purge META_DIR/ziptmp/download-*.zip older than 6h before creating a new ZIP
|
||||
|
||||
Result: fixes “failed to fetch / load failed” with fetch>blob flow and reduces leftover tmp ZIPs.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/4/2025 (v1.8.6)
|
||||
|
||||
release(v1.8.6): fix large ZIP downloads + safer extract; close #60
|
||||
|
||||
@@ -29,8 +29,7 @@ New: Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **Pow
|
||||
|
||||
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
|
||||
|
||||
**Dark mode:**
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server
|
||||
define('ONLYOFFICE_DEBUG', true);
|
||||
*/
|
||||
|
||||
if (!defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD')) {
|
||||
define('OIDC_TOKEN_ENDPOINT_AUTH_METHOD', 'client_secret_basic'); // default
|
||||
}
|
||||
|
||||
// Encryption helpers
|
||||
function encryptData($data, $encryptionKey)
|
||||
{
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
# --------------------------------
|
||||
# FileRise portable .htaccess
|
||||
# --------------------------------
|
||||
Options -Indexes
|
||||
Options -Indexes -Multiviews
|
||||
DirectoryIndex index.html
|
||||
|
||||
# Allow PATH_INFO for routes like /webdav.php/foo/bar
|
||||
AcceptPathInfo On
|
||||
|
||||
# ---------------- Security: dotfiles ----------------
|
||||
<IfModule mod_authz_core.c>
|
||||
# Block dotfiles like .env, .git, etc., but allow ACME under .well-known
|
||||
<FilesMatch "^\.(?!well-known(?:/|$))">
|
||||
# Block direct access to dotfiles like .env, .gitignore, etc.
|
||||
<FilesMatch "^\..*">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
@@ -15,15 +19,28 @@ DirectoryIndex index.html
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Never redirect local/dev hosts
|
||||
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# Let ACME http-01 pass BEFORE any redirect (needed for auto-renew)
|
||||
# 0) Let ACME http-01 pass BEFORE any other rule (needed for auto-renew)
|
||||
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
|
||||
RewriteRule - - [L]
|
||||
|
||||
# HTTPS redirect (enable ONE of these, comment the other)
|
||||
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
|
||||
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
||||
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
||||
|
||||
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
|
||||
# - allow /api/*.php (API endpoints)
|
||||
# - allow /api.php (ReDoc/spec page)
|
||||
# - allow /webdav.php (SabreDAV front)
|
||||
RewriteCond %{REQUEST_URI} !^/api/ [NC]
|
||||
RewriteCond %{REQUEST_URI} !^/api\.php$ [NC]
|
||||
RewriteCond %{REQUEST_URI} !^/webdav\.php$ [NC]
|
||||
RewriteRule \.php$ - [F,L]
|
||||
|
||||
# 3) Never redirect local/dev hosts
|
||||
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# 4) HTTPS redirect (enable ONE of these, comment the other)
|
||||
|
||||
# A) Direct TLS on this server
|
||||
#RewriteCond %{HTTPS} !=on
|
||||
@@ -35,7 +52,7 @@ RewriteRule - - [L]
|
||||
#RewriteCond %{HTTPS} !=on
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# Mark versioned assets (?v=...) with env flag for caching rules below
|
||||
# 5) Mark versioned assets (?v=...) with env flag for caching rules below
|
||||
RewriteCond %{QUERY_STRING} (^|&)v= [NC]
|
||||
RewriteRule ^ - [E=IS_VER:1]
|
||||
</IfModule>
|
||||
@@ -98,7 +115,6 @@ RewriteRule ^ - [E=IS_VER:1]
|
||||
|
||||
# ---------------- Compression ----------------
|
||||
<IfModule mod_brotli.c>
|
||||
# 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>
|
||||
|
||||
24
public/api/file/downloadZipFile.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// public/api/file/downloadZipFile.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/file/downloadZipFile.php",
|
||||
* summary="Download a finished ZIP by token",
|
||||
* description="Streams the zip once; token is one-shot.",
|
||||
* operationId="downloadZipFile",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
|
||||
* @OA\Parameter(name="name", in="query", required=false, @OA\Schema(type="string"), description="Suggested filename"),
|
||||
* @OA\Response(response=200, description="ZIP stream"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=404, description="Not found")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$controller = new FileController();
|
||||
$controller->downloadZipFile();
|
||||
23
public/api/file/zipStatus.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
// public/api/file/zipStatus.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/file/zipStatus.php",
|
||||
* summary="Check status of a background ZIP build",
|
||||
* description="Returns status for the authenticated user's token.",
|
||||
* operationId="zipStatus",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
|
||||
* @OA\Response(response=200, description="Status payload"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=404, description="Not found")
|
||||
* )
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$controller = new FileController();
|
||||
$controller->zipStatus();
|
||||
30
public/api/folder/isEmpty.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// public/api/folder/isEmpty.php
|
||||
declare(strict_types=1);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
// Snapshot then release session lock so parallel requests don’t block
|
||||
$user = (string)($_SESSION['username'] ?? '');
|
||||
$perms = [
|
||||
'role' => $_SESSION['role'] ?? null,
|
||||
'admin' => $_SESSION['admin'] ?? null,
|
||||
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||
];
|
||||
@session_write_close();
|
||||
|
||||
// Input
|
||||
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||
$folder = str_replace('\\', '/', trim($folder));
|
||||
$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/');
|
||||
|
||||
// Delegate to controller (model handles ACL + path safety)
|
||||
$result = FolderController::stats($folder, $user, $perms);
|
||||
|
||||
// Always return a compact JSON object like before
|
||||
echo json_encode([
|
||||
'folders' => (int)($result['folders'] ?? 0),
|
||||
'files' => (int)($result['files'] ?? 0),
|
||||
]);
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 17 KiB |
BIN
public/assets/logo-128.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/assets/logo-16.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
public/assets/logo-192.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/assets/logo-256.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/assets/logo-32.png
Normal file
|
After Width: | Height: | Size: 749 B |
BIN
public/assets/logo-48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/assets/logo-64.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.5 KiB |
@@ -141,7 +141,15 @@ body {
|
||||
}#userDropdownToggle {
|
||||
border-radius: 4px !important;
|
||||
padding: 6px 10px !important;
|
||||
}.header-buttons button:hover {
|
||||
}
|
||||
|
||||
#headerDropArea.header-drop-zone{
|
||||
display: flex;
|
||||
justify-content: flex-end; /* buttons to the right */
|
||||
align-items: center;
|
||||
min-height: 40px; /* so the label has room */
|
||||
}
|
||||
.header-buttons button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
color: #fff;
|
||||
@@ -1124,7 +1132,7 @@ body {
|
||||
border-radius: 4px;
|
||||
}.folder-tree {
|
||||
list-style-type: none;
|
||||
padding-left: 10px;
|
||||
padding-left: 5px;
|
||||
margin: 0;
|
||||
}.folder-tree.collapsed {
|
||||
display: none;
|
||||
@@ -1141,7 +1149,7 @@ body {
|
||||
text-align: right;
|
||||
}.folder-indent-placeholder {
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
width: 5px;
|
||||
}#folderTreeContainer {
|
||||
display: block;
|
||||
}.folder-option {
|
||||
@@ -1524,7 +1532,16 @@ body {
|
||||
.drag-header.active {
|
||||
width: 350px;
|
||||
height: 750px;
|
||||
}.main-column {
|
||||
}
|
||||
/* Fixed-width sidebar (always 350px) */
|
||||
#sidebarDropArea{
|
||||
width: 350px;
|
||||
min-width: 350px;
|
||||
max-width: 350px;
|
||||
flex: 0 0 350px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.main-column {
|
||||
flex: 1;
|
||||
transition: margin-left 0.3s ease;
|
||||
}#uploadFolderRow {
|
||||
@@ -1592,8 +1609,8 @@ body {
|
||||
}#sidebarDropArea,
|
||||
#uploadFolderRow {
|
||||
background-color: transparent;
|
||||
|
||||
}.dark-mode #sidebarDropArea,
|
||||
}
|
||||
.dark-mode #sidebarDropArea,
|
||||
.dark-mode #uploadFolderRow {
|
||||
background-color: transparent;
|
||||
}.dark-mode #sidebarDropArea.highlight,
|
||||
@@ -1607,8 +1624,6 @@ body {
|
||||
border: none !important;
|
||||
}.dragging:focus {
|
||||
outline: none;
|
||||
}#sidebarDropArea > .card {
|
||||
margin-bottom: 1rem;
|
||||
}.card {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
@@ -1705,8 +1720,9 @@ body {
|
||||
border: 2px dashed #555;
|
||||
color: #fff;
|
||||
}.header-drop-zone.drag-active:empty::before {
|
||||
content: "Drop";
|
||||
content: "Drop Zone";
|
||||
font-size: 10px;
|
||||
padding-right: 6px;
|
||||
color: #aaa;
|
||||
}/* Disable text selection on rows to prevent accidental copying when shift-clicking */
|
||||
#fileList tbody tr.clickable-row {
|
||||
@@ -1919,10 +1935,191 @@ body {
|
||||
color: #fff;
|
||||
}
|
||||
.status-badge.watched {
|
||||
border-color: rgba(34,197,94,.35); /* green-ish */
|
||||
border-color: rgba(34,197,94,.45); /* 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);
|
||||
}
|
||||
border-color: rgba(234,88,12,.55); /* amber-ish */
|
||||
background: rgba(234,88,12,.18);
|
||||
}
|
||||
#downloadProgressModal .modal-body,
|
||||
#downloadProgressModal .rise-modal-body,
|
||||
#downloadProgressModal .modal-content {
|
||||
min-height: 88px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#downloadProgressText {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#downloadProgressBarOuter { height: 10px; }
|
||||
|
||||
/* ===== FileRise Folder Tree: unified, crisp, aligned ===== */
|
||||
|
||||
/* Knobs (size, spacing, colors) */
|
||||
#folderTreeContainer {
|
||||
/* Colors (used in BOTH themes) */
|
||||
--filr-folder-front: #f6b84e; /* front/lip */
|
||||
--filr-folder-back: #ffd36e; /* back body */
|
||||
--filr-folder-stroke:#a87312; /* outline */
|
||||
--filr-paper-fill: #ffffff; /* paper */
|
||||
--filr-paper-stroke: #b2c2db; /* paper edges/lines */
|
||||
|
||||
/* Size & spacing */
|
||||
--row-h: 28px; /* row height */
|
||||
--twisty: 24px; /* chevron hit-area size */
|
||||
--twisty-gap: -5px; /* gap between chevron and row content */
|
||||
--icon-size: 24px; /* 22–26 look good */
|
||||
--icon-gap: 6px; /* space between icon and label */
|
||||
--indent: 10px; /* subtree indent */
|
||||
}
|
||||
|
||||
/* Keep the same yellow/orange in dark mode; boost paper contrast a touch */
|
||||
.dark-mode #folderTreeContainer {
|
||||
--filr-folder-front: #f6b84e;
|
||||
--filr-folder-back: #ffd36e;
|
||||
--filr-folder-stroke:#a87312;
|
||||
--filr-paper-fill: #ffffff;
|
||||
--filr-paper-stroke: #d0def7; /* brighter so it pops on dark */
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-item { position: static; padding-left: 0; }
|
||||
|
||||
/* visible “row” for each node */
|
||||
#folderTreeContainer .folder-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--row-h);
|
||||
padding-left: calc(var(--twisty) + var(--twisty-gap));
|
||||
}
|
||||
|
||||
/* children indent */
|
||||
#folderTreeContainer .folder-item > .folder-tree { margin-left: var(--indent); }
|
||||
|
||||
/* ---------- Chevron toggle (twisty) ---------- */
|
||||
|
||||
#folderTreeContainer .folder-row > button.folder-toggle {
|
||||
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: var(--twisty); height: var(--twisty);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid transparent; border-radius: 6px;
|
||||
background: transparent; cursor: pointer;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-row > button.folder-toggle::before {
|
||||
content: "▸"; /* closed */
|
||||
font-size: calc(var(--twisty) * 0.8);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#folderTreeContainer li[role="treeitem"][aria-expanded="true"]
|
||||
> .folder-row > button.folder-toggle::before { content: "▾"; }
|
||||
|
||||
/* root row (it's a <div>) */
|
||||
#rootRow[aria-expanded="true"] > button.folder-toggle::before { content: "▾"; }
|
||||
|
||||
#folderTreeContainer .folder-row > button.folder-toggle:hover {
|
||||
border-color: color-mix(in srgb, #7ab3ff 35%, transparent);
|
||||
}
|
||||
|
||||
/* spacer for leaves so labels align with parents that have a button */
|
||||
#folderTreeContainer .folder-row > .folder-spacer {
|
||||
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: var(--twisty); height: var(--twisty); display: inline-block;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: var(--row-h);
|
||||
line-height: 1.2; /* avoids baseline weirdness */
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
gap: var(--icon-gap);
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transform: translateY(0.5px); /* tiny optical nudge for text */
|
||||
}
|
||||
|
||||
/* ---------- Icon box (size & alignment) ---------- */
|
||||
|
||||
#folderTreeContainer .folder-icon {
|
||||
flex: 0 0 var(--icon-size);
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translateY(0.5px); /* tiny optical nudge for SVG */
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
/* ---------- Crisp colors & strokes for the SVG parts ---------- */
|
||||
|
||||
#folderTreeContainer .folder-icon .folder-front,
|
||||
#folderTreeContainer .folder-icon .folder-back {
|
||||
fill: currentColor;
|
||||
stroke: var(--filr-folder-stroke);
|
||||
stroke-width: 1.1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); }
|
||||
#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); }
|
||||
|
||||
#folderTreeContainer .folder-icon .paper {
|
||||
fill: var(--filr-paper-fill);
|
||||
stroke: var(--filr-paper-stroke);
|
||||
stroke-width: 1.5; /* thick so it reads at 24px */
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon .paper-fold {
|
||||
fill: var(--filr-paper-stroke);
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon .paper-line {
|
||||
stroke: var(--filr-paper-stroke);
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
fill: none;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* subtle highlight along lip to add depth */
|
||||
#folderTreeContainer .folder-icon .lip-highlight {
|
||||
stroke: #ffffff;
|
||||
stroke-opacity: .35;
|
||||
stroke-width: 0.9;
|
||||
fill: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
/* ---------- Hover / Selected ---------- */
|
||||
|
||||
#folderTreeContainer .folder-option:hover {
|
||||
background: rgba(122,179,255,.14);
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-option.selected {
|
||||
background: rgba(122,179,255,.24);
|
||||
box-shadow: inset 0 0 0 1px rgba(122,179,255,.45);
|
||||
}
|
||||
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -3,17 +3,24 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
||||
<meta name="theme-color" content="#0b5ed7">
|
||||
<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>
|
||||
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||
<!-- Favicons (ordered: SVG -> PNGs -> ICO) -->
|
||||
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
|
||||
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
|
||||
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||
|
||||
<meta name="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">
|
||||
<meta name="csrf-token" content=""><meta name="share-url" content=""><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}}">
|
||||
|
||||
<!-- Critical CSS -->
|
||||
<!-- 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}}">
|
||||
|
||||
@@ -58,7 +58,7 @@ function wireHeaderTitleLive() {
|
||||
|
||||
function renderMaskedInput({ id, label, hasValue, isSecret = false }) {
|
||||
const type = isSecret ? 'password' : 'text';
|
||||
const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : '';
|
||||
const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : 'data-replace="1"';
|
||||
const replaceBtn = hasValue
|
||||
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
|
||||
: '';
|
||||
@@ -1070,11 +1070,15 @@ function handleSave() {
|
||||
const idEl = document.getElementById("oidcClientId");
|
||||
const scEl = document.getElementById("oidcClientSecret");
|
||||
|
||||
if (idEl?.dataset.replace === '1' && idEl.value.trim() !== '') {
|
||||
payload.oidc.clientId = idEl.value.trim();
|
||||
const idVal = idEl?.value.trim() || '';
|
||||
const secVal = scEl?.value.trim() || '';
|
||||
const idFirstTime = idEl && !idEl.hasAttribute('data-replace'); // no saved value yet
|
||||
const secFirstTime = scEl && !scEl.hasAttribute('data-replace'); // no saved value yet
|
||||
if ((idEl?.dataset.replace === '1' || idFirstTime) && idVal !== '') {
|
||||
payload.oidc.clientId = idVal;
|
||||
}
|
||||
if (scEl?.dataset.replace === '1' && scEl.value.trim() !== '') {
|
||||
payload.oidc.clientSecret = scEl.value.trim();
|
||||
if ((scEl?.dataset.replace === '1' || secFirstTime) && secVal !== '') {
|
||||
payload.oidc.clientSecret = secVal;
|
||||
}
|
||||
|
||||
const ooSecretEl = document.getElementById("ooJwtSecret");
|
||||
|
||||
@@ -119,7 +119,7 @@ export async function handleCreateFile(e) {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type':'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken
|
||||
},
|
||||
// ⚠️ must send `name`, not `filename`
|
||||
@@ -139,7 +139,7 @@ export async function handleCreateFile(e) {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const cancel = document.getElementById('cancelCreateFile');
|
||||
const confirm = document.getElementById('confirmCreateFile');
|
||||
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
|
||||
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
|
||||
if (confirm) confirm.addEventListener('click', handleCreateFile);
|
||||
});
|
||||
|
||||
@@ -265,7 +265,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
||||
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
||||
const cancelCreate = document.getElementById('cancelCreateFile');
|
||||
|
||||
|
||||
if (cancelCreate) {
|
||||
cancelCreate.addEventListener('click', () => {
|
||||
document.getElementById('createFileModal').style.display = 'none';
|
||||
@@ -305,7 +305,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
showToast(err.message || t('error_creating_file'));
|
||||
}
|
||||
});
|
||||
attachEnterKeyListener('createFileModal','confirmCreateFile');
|
||||
attachEnterKeyListener('createFileModal', 'confirmCreateFile');
|
||||
}
|
||||
|
||||
// 1) Cancel button hides the name modal
|
||||
@@ -321,63 +321,187 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
confirmZipBtn.addEventListener("click", async () => {
|
||||
// a) Validate ZIP filename
|
||||
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
||||
if (!zipName) {
|
||||
showToast("Please enter a name for the zip file.");
|
||||
return;
|
||||
}
|
||||
if (!zipName.toLowerCase().endsWith(".zip")) {
|
||||
zipName += ".zip";
|
||||
}
|
||||
if (!zipName) { showToast("Please enter a name for the zip file."); return; }
|
||||
if (!zipName.toLowerCase().endsWith(".zip")) zipName += ".zip";
|
||||
|
||||
// b) Hide the name‐input modal, show the spinner modal
|
||||
// b) Hide the name‐input modal, show the progress modal
|
||||
zipNameModal.style.display = "none";
|
||||
progressModal.style.display = "block";
|
||||
|
||||
// c) (Optional) update the “Preparing…” text if you gave it an ID
|
||||
// c) Title text (optional)
|
||||
const titleEl = document.getElementById("downloadProgressTitle");
|
||||
if (titleEl) titleEl.textContent = `Preparing ${zipName}…`;
|
||||
|
||||
try {
|
||||
// d) POST and await the ZIP blob
|
||||
const res = await fetch("/api/file/downloadZip.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder: window.currentFolder || "root",
|
||||
files: window.filesToDownload
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(txt || `Status ${res.status}`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error("Received empty ZIP file.");
|
||||
}
|
||||
|
||||
// e) Hand off to the browser’s download manager
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = zipName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error downloading ZIP:", err);
|
||||
showToast("Error: " + err.message);
|
||||
} finally {
|
||||
// f) Always hide spinner modal
|
||||
progressModal.style.display = "none";
|
||||
// d) Queue the job
|
||||
const res = await fetch("/api/file/downloadZip.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder: window.currentFolder || "root", files: window.filesToDownload })
|
||||
});
|
||||
const jsr = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !jsr.ok) {
|
||||
const msg = (jsr && jsr.error) ? jsr.error : `Status ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
const token = jsr.token;
|
||||
const statusUrl = jsr.statusUrl;
|
||||
const downloadUrl = jsr.downloadUrl + "&name=" + encodeURIComponent(zipName);
|
||||
|
||||
// Ensure a progress UI exists in the modal
|
||||
function ensureZipProgressUI() {
|
||||
const modalEl = document.getElementById("downloadProgressModal");
|
||||
if (!modalEl) {
|
||||
// really shouldn't happen, but fall back to body
|
||||
console.warn("downloadProgressModal not found; falling back to document.body");
|
||||
}
|
||||
// Prefer a dedicated content node inside the modal
|
||||
let host =
|
||||
(modalEl && modalEl.querySelector("#downloadProgressContent")) ||
|
||||
(modalEl && modalEl.querySelector(".modal-body")) ||
|
||||
(modalEl && modalEl.querySelector(".rise-modal-body")) ||
|
||||
(modalEl && modalEl.querySelector(".modal-content")) ||
|
||||
(modalEl && modalEl.querySelector(".content")) ||
|
||||
null;
|
||||
|
||||
// If no suitable container, create one inside the modal
|
||||
if (!host) {
|
||||
host = document.createElement("div");
|
||||
host.id = "downloadProgressContent";
|
||||
(modalEl || document.body).appendChild(host);
|
||||
}
|
||||
|
||||
// Helper: ensure/move an element with given id into host
|
||||
function ensureInHost(id, tag, init) {
|
||||
let el = document.getElementById(id);
|
||||
if (el && el.parentElement !== host) host.appendChild(el); // move if it exists elsewhere
|
||||
if (!el) {
|
||||
el = document.createElement(tag);
|
||||
el.id = id;
|
||||
if (typeof init === "function") init(el);
|
||||
host.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// Title
|
||||
const title = ensureInHost("downloadProgressTitle", "div", (el) => {
|
||||
el.style.marginBottom = "8px";
|
||||
el.textContent = "Preparing…";
|
||||
});
|
||||
|
||||
// Progress bar (native <progress>)
|
||||
const bar = (function () {
|
||||
let el = document.getElementById("downloadProgressBar");
|
||||
if (el && el.parentElement !== host) host.appendChild(el); // move into modal
|
||||
if (!el) {
|
||||
el = document.createElement("progress");
|
||||
el.id = "downloadProgressBar";
|
||||
host.appendChild(el);
|
||||
}
|
||||
el.max = 100;
|
||||
el.value = 0;
|
||||
el.style.display = ""; // override any inline display:none
|
||||
el.style.width = "100%";
|
||||
el.style.height = "1.1em";
|
||||
return el;
|
||||
})();
|
||||
|
||||
// Text line
|
||||
const text = ensureInHost("downloadProgressText", "div", (el) => {
|
||||
el.style.marginTop = "8px";
|
||||
el.style.fontSize = "0.9rem";
|
||||
el.style.whiteSpace = "nowrap";
|
||||
el.style.overflow = "hidden";
|
||||
el.style.textOverflow = "ellipsis";
|
||||
});
|
||||
|
||||
// Optional spinner hider
|
||||
const hideSpinner = () => {
|
||||
const sp = document.getElementById("downloadSpinner");
|
||||
if (sp) sp.style.display = "none";
|
||||
};
|
||||
|
||||
return { bar, text, title, hideSpinner };
|
||||
}
|
||||
|
||||
function humanBytes(n) {
|
||||
if (!Number.isFinite(n) || n < 0) return "";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"]; let i = 0, x = n;
|
||||
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
|
||||
return x.toFixed(x >= 10 || i === 0 ? 0 : 1) + " " + u[i];
|
||||
}
|
||||
function mmss(sec) {
|
||||
sec = Math.max(0, sec | 0);
|
||||
const m = (sec / 60) | 0, s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const ui = ensureZipProgressUI();
|
||||
const t0 = Date.now();
|
||||
|
||||
// e) Poll until ready
|
||||
while (true) {
|
||||
await new Promise(r => setTimeout(r, 1200));
|
||||
const s = await fetch(`${statusUrl}&_=${Date.now()}`, {
|
||||
credentials: "include", cache: "no-store",
|
||||
}).then(r => r.json());
|
||||
|
||||
if (s.error) throw new Error(s.error);
|
||||
if (ui.title) ui.title.textContent = `Preparing ${zipName}…`;
|
||||
|
||||
// --- RENDER PROGRESS ---
|
||||
if (typeof s.pct === "number" && ui.bar && ui.text) {
|
||||
if ((s.phase !== 'finalizing') && (s.pct < 99)) {
|
||||
ui.hideSpinner && ui.hideSpinner();
|
||||
const filesDone = s.filesDone ?? 0;
|
||||
const filesTotal = s.filesTotal ?? 0;
|
||||
const bytesDone = s.bytesDone ?? 0;
|
||||
const bytesTotal = s.bytesTotal ?? 0;
|
||||
|
||||
// Determinate 0–98% while enumerating
|
||||
const pct = Math.max(0, Math.min(98, s.pct | 0));
|
||||
if (!ui.bar.hasAttribute("value")) ui.bar.value = 0;
|
||||
ui.bar.value = pct;
|
||||
ui.text.textContent =
|
||||
`${pct}% — ${filesDone}/${filesTotal} files, ${humanBytes(bytesDone)} / ${humanBytes(bytesTotal)}`;
|
||||
} else {
|
||||
// FINALIZING: keep progress at 100% and show timer + selected totals
|
||||
if (!ui.bar.hasAttribute("value")) ui.bar.value = 100;
|
||||
ui.bar.value = 100; // lock at 100 during finalizing
|
||||
const since = s.finalizeAt ? Math.max(0, (Date.now() / 1000 | 0) - (s.finalizeAt | 0)) : 0;
|
||||
const selF = s.selectedFiles ?? s.filesTotal ?? 0;
|
||||
const selB = s.selectedBytes ?? s.bytesTotal ?? 0;
|
||||
ui.text.textContent = `Finalizing… ${mmss(since)} — ${selF} file${selF === 1 ? '' : 's'}, ~${humanBytes(selB)}`;
|
||||
}
|
||||
} else if (ui.text) {
|
||||
ui.text.textContent = "Still preparing…";
|
||||
}
|
||||
// --- /RENDER ---
|
||||
|
||||
if (s.ready) {
|
||||
// Snap to 100 and close modal just before download
|
||||
if (ui.bar) { ui.bar.max = 100; ui.bar.value = 100; }
|
||||
progressModal.style.display = "none";
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
break;
|
||||
}
|
||||
if (Date.now() - t0 > 15 * 60 * 1000) throw new Error("Timed out preparing ZIP");
|
||||
}
|
||||
|
||||
// f) Trigger download
|
||||
const a = document.createElement("a");
|
||||
a.href = downloadUrl;
|
||||
a.download = zipName;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
|
||||
// g) Reset for next time
|
||||
if (ui.bar) ui.bar.value = 0;
|
||||
if (ui.text) ui.text.textContent = "";
|
||||
if (Array.isArray(window.filesToDownload)) window.filesToDownload = [];
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -694,10 +818,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = document.getElementById('createBtn');
|
||||
const menu = document.getElementById('createMenu');
|
||||
const fileOpt = document.getElementById('createFileOption');
|
||||
const folderOpt= document.getElementById('createFolderOption');
|
||||
const btn = document.getElementById('createBtn');
|
||||
const menu = document.getElementById('createMenu');
|
||||
const fileOpt = document.getElementById('createFileOption');
|
||||
const folderOpt = document.getElementById('createFolderOption');
|
||||
|
||||
// Toggle dropdown on click
|
||||
btn.addEventListener('click', (e) => {
|
||||
|
||||
@@ -2,124 +2,163 @@
|
||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
|
||||
export function fileDragStartHandler(event) {
|
||||
const row = event.currentTarget;
|
||||
let fileNames = [];
|
||||
|
||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||
if (selectedCheckboxes.length > 1) {
|
||||
selectedCheckboxes.forEach(chk => {
|
||||
const parentRow = chk.closest("tr");
|
||||
if (parentRow) {
|
||||
const cell = parentRow.querySelector("td:nth-child(2)");
|
||||
if (cell) {
|
||||
let rawName = cell.textContent.trim();
|
||||
const tagContainer = cell.querySelector(".tag-badges");
|
||||
if (tagContainer) {
|
||||
const tagText = tagContainer.innerText.trim();
|
||||
if (rawName.endsWith(tagText)) {
|
||||
rawName = rawName.slice(0, -tagText.length).trim();
|
||||
}
|
||||
}
|
||||
fileNames.push(rawName);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const fileNameCell = row.querySelector("td:nth-child(2)");
|
||||
if (fileNameCell) {
|
||||
let rawName = fileNameCell.textContent.trim();
|
||||
const tagContainer = fileNameCell.querySelector(".tag-badges");
|
||||
if (tagContainer) {
|
||||
const tagText = tagContainer.innerText.trim();
|
||||
if (rawName.endsWith(tagText)) {
|
||||
rawName = rawName.slice(0, -tagText.length).trim();
|
||||
}
|
||||
}
|
||||
fileNames.push(rawName);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileNames.length === 0) return;
|
||||
|
||||
const dragData = fileNames.length === 1
|
||||
? { fileName: fileNames[0], sourceFolder: window.currentFolder || "root" }
|
||||
: { files: fileNames, sourceFolder: window.currentFolder || "root" };
|
||||
|
||||
event.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
|
||||
let dragImage = document.createElement("div");
|
||||
dragImage.style.display = "inline-flex";
|
||||
dragImage.style.width = "auto";
|
||||
dragImage.style.maxWidth = "fit-content";
|
||||
dragImage.style.padding = "6px 10px";
|
||||
dragImage.style.backgroundColor = "#333";
|
||||
dragImage.style.color = "#fff";
|
||||
dragImage.style.border = "1px solid #555";
|
||||
dragImage.style.borderRadius = "4px";
|
||||
dragImage.style.alignItems = "center";
|
||||
dragImage.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.3)";
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "material-icons";
|
||||
icon.textContent = "insert_drive_file";
|
||||
icon.style.marginRight = "4px";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = fileNames.length === 1 ? fileNames[0] : fileNames.length + " files";
|
||||
dragImage.appendChild(icon);
|
||||
dragImage.appendChild(label);
|
||||
|
||||
document.body.appendChild(dragImage);
|
||||
event.dataTransfer.setDragImage(dragImage, 5, 5);
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(dragImage);
|
||||
}, 0);
|
||||
/* ---------------- helpers ---------------- */
|
||||
function getRowEl(el) {
|
||||
return el?.closest('tr[data-file-name], .gallery-card[data-file-name]') || null;
|
||||
}
|
||||
function getNameFromAny(el) {
|
||||
const row = getRowEl(el);
|
||||
if (!row) return null;
|
||||
// 1) canonical
|
||||
const n = row.getAttribute('data-file-name');
|
||||
if (n) return n;
|
||||
// 2) filename-only span
|
||||
const span = row.querySelector('.filename-text');
|
||||
if (span) return span.textContent.trim();
|
||||
return null;
|
||||
}
|
||||
function getSelectedFileNames() {
|
||||
const boxes = Array.from(document.querySelectorAll('#fileList .file-checkbox:checked'));
|
||||
const names = boxes.map(cb => getNameFromAny(cb)).filter(Boolean);
|
||||
// de-dup just in case
|
||||
return Array.from(new Set(names));
|
||||
}
|
||||
function makeDragImage(labelText, iconName = 'insert_drive_file') {
|
||||
const wrap = document.createElement('div');
|
||||
Object.assign(wrap.style, {
|
||||
display: 'inline-flex',
|
||||
maxWidth: '420px',
|
||||
padding: '6px 10px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '6px',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
boxShadow: '2px 2px 6px rgba(0,0,0,0.3)',
|
||||
fontSize: '12px',
|
||||
pointerEvents: 'none'
|
||||
});
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'material-icons';
|
||||
icon.textContent = iconName;
|
||||
const label = document.createElement('span');
|
||||
// trim long single-name labels
|
||||
const txt = String(labelText || '');
|
||||
label.textContent = txt.length > 60 ? (txt.slice(0, 57) + '…') : txt;
|
||||
wrap.appendChild(icon);
|
||||
wrap.appendChild(label);
|
||||
document.body.appendChild(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* ---------------- drag start (rows/cards) ---------------- */
|
||||
export function fileDragStartHandler(event) {
|
||||
const row = getRowEl(event.currentTarget);
|
||||
if (!row) return;
|
||||
|
||||
// Use current selection if present; otherwise drag just this row’s file
|
||||
let names = getSelectedFileNames();
|
||||
if (names.length === 0) {
|
||||
const single = getNameFromAny(row);
|
||||
if (single) names = [single];
|
||||
}
|
||||
if (names.length === 0) return;
|
||||
|
||||
const sourceFolder = window.currentFolder || 'root';
|
||||
const payload = { files: names, sourceFolder };
|
||||
|
||||
// primary payload
|
||||
event.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||
// fallback (lets some environments read something human)
|
||||
event.dataTransfer.setData('text/plain', names.join('\n'));
|
||||
|
||||
// nicer drag image
|
||||
const dragLabel = (names.length === 1) ? names[0] : `${names.length} files`;
|
||||
const ghost = makeDragImage(dragLabel, names.length === 1 ? 'insert_drive_file' : 'folder');
|
||||
event.dataTransfer.setDragImage(ghost, 6, 6);
|
||||
// clean up the ghost as soon as the browser has captured it
|
||||
setTimeout(() => { try { document.body.removeChild(ghost); } catch { } }, 0);
|
||||
}
|
||||
|
||||
/* ---------------- folder targets ---------------- */
|
||||
export function folderDragOverHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.add("drop-hover");
|
||||
event.currentTarget.classList.add('drop-hover');
|
||||
}
|
||||
|
||||
export function folderDragLeaveHandler(event) {
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
event.currentTarget.classList.remove('drop-hover');
|
||||
}
|
||||
|
||||
export function folderDropHandler(event) {
|
||||
export async function folderDropHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
||||
let dragData;
|
||||
event.currentTarget.classList.remove('drop-hover');
|
||||
|
||||
const dropFolder = event.currentTarget.getAttribute('data-folder')
|
||||
|| event.currentTarget.getAttribute('data-dest-folder')
|
||||
|| 'root';
|
||||
|
||||
// parse drag payload
|
||||
let dragData = null;
|
||||
try {
|
||||
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
|
||||
} catch (e) {
|
||||
console.error("Invalid drag data");
|
||||
const raw = event.dataTransfer.getData('application/json') || '{}';
|
||||
dragData = JSON.parse(raw);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!dragData) {
|
||||
showToast('Invalid drag data.');
|
||||
return;
|
||||
}
|
||||
if (!dragData || !dragData.fileName) return;
|
||||
fetch("/api/file/moveFiles.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: dragData.sourceFolder,
|
||||
files: [dragData.fileName],
|
||||
destination: dropFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(`File "${dragData.fileName}" moved successfully to ${dropFolder}!`);
|
||||
loadFileList(dragData.sourceFolder);
|
||||
} else {
|
||||
showToast("Error moving file: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error moving file via drop:", error);
|
||||
showToast("Error moving file.");
|
||||
|
||||
// normalize names
|
||||
let names = Array.isArray(dragData.files) ? dragData.files.slice()
|
||||
: dragData.fileName ? [dragData.fileName]
|
||||
: [];
|
||||
names = names.filter(v => typeof v === 'string' && v.length > 0);
|
||||
|
||||
if (names.length === 0) {
|
||||
showToast('No files to move.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceFolder = dragData.sourceFolder || (window.currentFolder || 'root');
|
||||
if (dropFolder === sourceFolder) {
|
||||
showToast('Source and destination are the same.');
|
||||
return;
|
||||
}
|
||||
|
||||
// POST move
|
||||
try {
|
||||
const res = await fetch('/api/file/moveFiles.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: sourceFolder,
|
||||
files: names,
|
||||
destination: dropFolder
|
||||
})
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (res.ok && data && data.success) {
|
||||
const msg = (names.length === 1)
|
||||
? `Moved "${names[0]}" to ${dropFolder}.`
|
||||
: `Moved ${names.length} files to ${dropFolder}.`;
|
||||
showToast(msg);
|
||||
// Refresh whatever view the user is currently looking at
|
||||
loadFileList(window.currentFolder || sourceFolder);
|
||||
} else {
|
||||
const err = (data && (data.error || data.message)) || `HTTP ${res.status}`;
|
||||
showToast('Error moving file(s): ' + err);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error moving file(s):', e);
|
||||
showToast('Error moving file(s).');
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ function normalizeModeName(modeOption) {
|
||||
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 };
|
||||
let __ooCaps = { enabled: false, exts: new Set(), fetched: false, docsOrigin: null };
|
||||
|
||||
async function fetchOnlyOfficeCapsOnce() {
|
||||
if (__ooCaps.fetched) return __ooCaps;
|
||||
@@ -80,6 +80,7 @@ async function fetchOnlyOfficeCapsOnce() {
|
||||
const j = await r.json();
|
||||
__ooCaps.enabled = !!j.enabled;
|
||||
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
|
||||
__ooCaps.docsOrigin = j.docsOrigin || null; // harmless if server doesn't send it
|
||||
}
|
||||
} catch { /* ignore; keep defaults */ }
|
||||
__ooCaps.fetched = true;
|
||||
@@ -93,121 +94,23 @@ async function shouldUseOnlyOffice(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"}">×</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();
|
||||
|
||||
// We’ll 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 ----------------------------------------------
|
||||
|
||||
|
||||
// ---- script/css single-load with timeout guards ----
|
||||
const _loadedScripts = new Set();
|
||||
const _loadedCss = new Set();
|
||||
let _corePromise = null;
|
||||
|
||||
function loadScriptOnce(url) {
|
||||
function loadScriptOnce(url, timeoutMs = 12000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (_loadedScripts.has(url)) return resolve();
|
||||
const s = document.createElement("script");
|
||||
const timer = setTimeout(() => {
|
||||
try { s.remove(); } catch { }
|
||||
reject(new Error(`Timeout loading: ${url}`));
|
||||
}, timeoutMs);
|
||||
s.src = url;
|
||||
s.async = true;
|
||||
s.onload = () => { _loadedScripts.add(url); resolve(); };
|
||||
s.onerror = () => reject(new Error(`Load failed: ${url}`));
|
||||
s.onload = () => { clearTimeout(timer); _loadedScripts.add(url); resolve(); };
|
||||
s.onerror = () => { clearTimeout(timer); reject(new Error(`Load failed: ${url}`)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
@@ -240,7 +143,6 @@ async function ensureCore() {
|
||||
async function loadSingleMode(name) {
|
||||
const rel = MODE_URL[name];
|
||||
if (!rel) return;
|
||||
// prepend base if needed
|
||||
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
|
||||
await loadScriptOnce(url);
|
||||
}
|
||||
@@ -265,9 +167,299 @@ async function ensureModeLoaded(modeOption) {
|
||||
}
|
||||
|
||||
// Public helper for callers (we keep your existing function name in use):
|
||||
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
|
||||
const MODE_LOAD_TIMEOUT_MS = 300; // allow closing immediately; don't wait forever
|
||||
// ==== /CodeMirror lazy loader ===============================================
|
||||
|
||||
// ---- OO preconnect / prewarm ----
|
||||
function injectOOPreconnect(origin) {
|
||||
try {
|
||||
if (!origin || !isAbsoluteHttpUrl(origin)) return;
|
||||
const make = (rel) => { const l = document.createElement('link'); l.rel = rel; l.href = origin; return l; };
|
||||
document.head.appendChild(make('dns-prefetch'));
|
||||
document.head.appendChild(make('preconnect'));
|
||||
} catch { }
|
||||
}
|
||||
|
||||
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
|
||||
// Prefer explicit src; else derive from origin; else fall back to window/global or default prefix path
|
||||
let src = srcFromConfig;
|
||||
if (!src) {
|
||||
if (originFromConfig && isAbsoluteHttpUrl(originFromConfig)) {
|
||||
src = originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js';
|
||||
} else {
|
||||
src = window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js';
|
||||
}
|
||||
}
|
||||
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
|
||||
// Try once; if it times out and we derived from origin, fall back to the default prefix path
|
||||
try {
|
||||
console.time('oo:api.js');
|
||||
await loadScriptOnce(src);
|
||||
} catch (e) {
|
||||
if (src !== '/onlyoffice/web-apps/apps/api/documents/api.js') {
|
||||
await loadScriptOnce('/onlyoffice/web-apps/apps/api/documents/api.js');
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
console.timeEnd('oo:api.js');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ONLYOFFICE: full-screen modal + warm on every click =====
|
||||
const ALWAYS_WARM_OO = true; // warm EVERY time
|
||||
const OO_WARM_MS = 300;
|
||||
|
||||
function ensureOoModalCss() {
|
||||
const prev = document.getElementById('ooEditorModalCss');
|
||||
if (prev) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ooEditorModalCss';
|
||||
style.textContent = `
|
||||
#ooEditorModal{
|
||||
--oo-header-h: 40px;
|
||||
--oo-header-pad-v: 12px;
|
||||
--oo-header-pad-h: 18px;
|
||||
--oo-logo-h: 26px; /* tweak logo size */
|
||||
}
|
||||
|
||||
#ooEditorModal{
|
||||
position:fixed!important; inset:0!important; margin:0!important; padding:0!important;
|
||||
display:flex!important; flex-direction:column!important; z-index:2147483646!important;
|
||||
background:var(--oo-modal-bg,#111)!important;
|
||||
}
|
||||
|
||||
/* Header: logo (left) + title (fill) + absolute close (right) */
|
||||
#ooEditorModal .editor-header{
|
||||
position:relative; display:flex; align-items:center; gap:12px;
|
||||
min-height:var(--oo-header-h);
|
||||
padding:var(--oo-header-pad-v) var(--oo-header-pad-h);
|
||||
padding-right: calc(var(--oo-header-pad-h) + 64px); /* room for 32px round close */
|
||||
border-bottom:1px solid rgba(0,0,0,.15);
|
||||
box-sizing:border-box;
|
||||
}
|
||||
|
||||
#ooEditorModal .editor-logo{
|
||||
height:var(--oo-logo-h); width:auto; flex:0 0 auto;
|
||||
display:block; user-select:none; -webkit-user-drag:none;
|
||||
}
|
||||
|
||||
#ooEditorModal .editor-title{
|
||||
margin:0; font-size:18px; font-weight:700; line-height:1.2;
|
||||
overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
|
||||
flex:1 1 auto;
|
||||
}
|
||||
|
||||
/* Your scoped close button style */
|
||||
#ooEditorModal .editor-close-btn{
|
||||
position:absolute; top:5px; right:10px;
|
||||
display:flex; justify-content:center; align-items:center;
|
||||
font-size:20px; font-weight:bold; cursor:pointer; z-index:1000;
|
||||
width:32px; height:32px; border-radius:50%; text-align:center; line-height:30px;
|
||||
color:#ff4d4d; background-color:rgba(255,255,255,.9); border:2px solid transparent;
|
||||
transition:all .3s ease-in-out;
|
||||
}
|
||||
#ooEditorModal .editor-close-btn:hover{
|
||||
color:#fff; background-color:#ff4d4d;
|
||||
box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05);
|
||||
}
|
||||
.dark-mode #ooEditorModal .editor-close-btn{ background-color:rgba(0,0,0,.7); color:#ff6666; }
|
||||
.dark-mode #ooEditorModal .editor-close-btn:hover{ background-color:#ff6666; color:#000; }
|
||||
|
||||
#ooEditorModal .editor-body{
|
||||
position:relative!important; flex:1 1 auto!important; min-height:0!important; overflow:hidden!important;
|
||||
}
|
||||
#ooEditorModal #oo-editor{ width:100%!important; height:100%!important; }
|
||||
|
||||
#ooEditorModal .oo-warm-overlay{
|
||||
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
||||
background:rgba(0,0,0,.14); z-index:5; font-weight:600; font-size:14px;
|
||||
}
|
||||
|
||||
html.oo-lock, body.oo-lock{ height:100%!important; overflow:hidden!important; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Theme-aware background so there’s no white/gray edge
|
||||
function applyModalBg(modal){
|
||||
const isDark = document.documentElement.classList.contains('dark-mode')
|
||||
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const bg = (cs.getPropertyValue('--bg-color') || cs.getPropertyValue('--pre-bg') || '').trim()
|
||||
|| (isDark ? '#121212' : '#ffffff');
|
||||
modal.style.setProperty('--oo-modal-bg', bg);
|
||||
}
|
||||
|
||||
function lockPageScroll(on){
|
||||
[document.documentElement, document.body].forEach(el => el.classList.toggle('oo-lock', !!on));
|
||||
}
|
||||
|
||||
function ensureOoFullscreenModal(){
|
||||
ensureOoModalCss();
|
||||
let modal = document.getElementById('ooEditorModal');
|
||||
if (!modal){
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'ooEditorModal';
|
||||
modal.innerHTML = `
|
||||
<div class="editor-header">
|
||||
<img class="editor-logo" src="/assets/logo.svg" alt="FileRise logo" />
|
||||
<h3 class="editor-title"></h3>
|
||||
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">×</button>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<div id="oo-editor"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
} else {
|
||||
modal.querySelector('.editor-body').innerHTML = `<div id="oo-editor"></div>`;
|
||||
// ensure logo exists and is placed before title when reusing
|
||||
const header = modal.querySelector('.editor-header');
|
||||
if (!header.querySelector('.editor-logo')){
|
||||
const img = document.createElement('img');
|
||||
img.className = 'editor-logo';
|
||||
img.src = '/assets/logo.svg';
|
||||
img.alt = 'FileRise logo';
|
||||
header.insertBefore(img, header.querySelector('.editor-title'));
|
||||
} else {
|
||||
// make sure order is logo -> title
|
||||
const logo = header.querySelector('.editor-logo');
|
||||
const title = header.querySelector('.editor-title');
|
||||
if (logo.nextElementSibling !== title){
|
||||
header.insertBefore(logo, title);
|
||||
}
|
||||
}
|
||||
}
|
||||
applyModalBg(modal);
|
||||
modal.style.display = 'flex';
|
||||
modal.focus();
|
||||
lockPageScroll(true);
|
||||
return modal;
|
||||
}
|
||||
|
||||
// Overlay lives INSIDE the modal body
|
||||
function setOoBusy(modal, on, label='Preparing editor…'){
|
||||
if (!modal) return;
|
||||
const body = modal.querySelector('.editor-body');
|
||||
let ov = body.querySelector('.oo-warm-overlay');
|
||||
if (on){
|
||||
if (!ov){
|
||||
ov = document.createElement('div');
|
||||
ov.className = 'oo-warm-overlay';
|
||||
ov.textContent = label;
|
||||
body.appendChild(ov);
|
||||
}
|
||||
} else if (ov){
|
||||
ov.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden warm-up DocEditor (creates DS session/cache) then destroys
|
||||
async function warmDocServerOnce(cfg){
|
||||
let host = null, warmEditor = null;
|
||||
try{
|
||||
host = document.createElement('div');
|
||||
host.id = 'oo-warm-' + Math.random().toString(36).slice(2);
|
||||
Object.assign(host.style, {
|
||||
position:'absolute', left:'-99999px', top:'0', width:'2px', height:'2px', overflow:'hidden'
|
||||
});
|
||||
document.body.appendChild(host);
|
||||
|
||||
const warmCfg = JSON.parse(JSON.stringify(cfg));
|
||||
warmCfg.events = Object.assign({}, warmCfg.events, { onAppReady(){}, onDocumentReady(){} });
|
||||
|
||||
warmEditor = new window.DocsAPI.DocEditor(host.id, warmCfg);
|
||||
await new Promise(res => setTimeout(res, OO_WARM_MS));
|
||||
}catch{} finally{
|
||||
try{ warmEditor?.destroyEditor?.(); }catch{}
|
||||
try{ host?.remove(); }catch{}
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen OO open with hidden warm-up EVERY click, then real editor
|
||||
async function openOnlyOffice(fileName, folder){
|
||||
let editor = null;
|
||||
let removeThemeListener = () => {};
|
||||
let cfg = null;
|
||||
let userClosed = false;
|
||||
|
||||
// Build our full-screen modal
|
||||
const modal = ensureOoFullscreenModal();
|
||||
const titleEl = modal.querySelector('.editor-title');
|
||||
if (titleEl) titleEl.innerHTML = `${t("editing")}: ${escapeHTML(fileName)}`;
|
||||
|
||||
const destroy = (removeModal = true) => {
|
||||
try { editor?.destroyEditor?.(); } catch {}
|
||||
try { removeThemeListener(); } catch {}
|
||||
if (removeModal) { try { modal.remove(); } catch {} }
|
||||
lockPageScroll(false);
|
||||
};
|
||||
const onClose = () => { userClosed = true; destroy(true); };
|
||||
|
||||
modal.querySelector('#closeEditorX')?.addEventListener('click', onClose);
|
||||
modal.addEventListener('keydown', (e) => { if (e.key === 'Escape') onClose(); });
|
||||
|
||||
try{
|
||||
// 1) Fetch config
|
||||
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
|
||||
const resp = await fetch(url, { credentials: 'include' });
|
||||
const text = await resp.text();
|
||||
|
||||
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}`);
|
||||
|
||||
// 2) Preconnect + load DocsAPI
|
||||
injectOOPreconnect(cfg.documentServerOrigin || null);
|
||||
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
|
||||
|
||||
// 3) Theme + base events
|
||||
const isDark = document.documentElement.classList.contains('dark-mode')
|
||||
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||
cfg.events = (cfg.events && typeof cfg.events === 'object') ? cfg.events : {};
|
||||
cfg.editorConfig = cfg.editorConfig || {};
|
||||
cfg.editorConfig.customization = Object.assign(
|
||||
{}, cfg.editorConfig.customization, { uiTheme: isDark ? 'theme-dark' : 'theme-light' }
|
||||
);
|
||||
cfg.events.onRequestClose = () => onClose();
|
||||
|
||||
// 4) Warm EVERY click
|
||||
if (ALWAYS_WARM_OO && !userClosed){
|
||||
setOoBusy(modal, true); // overlay INSIDE modal body
|
||||
await warmDocServerOnce(cfg);
|
||||
if (userClosed) return;
|
||||
}
|
||||
|
||||
// 5) Launch visible editor in full-screen modal
|
||||
cfg.events.onDocumentReady = () => { setOoBusy(modal, false); };
|
||||
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
|
||||
|
||||
// Live theme switching + keep modal bg in sync
|
||||
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');
|
||||
}
|
||||
applyModalBg(modal);
|
||||
};
|
||||
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.');
|
||||
destroy(true);
|
||||
}
|
||||
}
|
||||
// ---- /ONLYOFFICE integration ----------------------------------------------
|
||||
|
||||
// ==== Editor (CodeMirror) path =============================================
|
||||
|
||||
function getModeForFile(fileName) {
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
||||
@@ -452,38 +644,36 @@ export async function editFile(fileName, folder) {
|
||||
const normName = normalizeModeName(desiredMode) || "text/plain";
|
||||
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
|
||||
|
||||
const cmOptions = {
|
||||
lineNumbers: !forcePlainText,
|
||||
mode: initialMode,
|
||||
theme,
|
||||
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||
lineWrapping: false
|
||||
};
|
||||
|
||||
const editor = window.CodeMirror.fromTextArea(
|
||||
const cm = window.CodeMirror.fromTextArea(
|
||||
document.getElementById("fileEditor"),
|
||||
cmOptions
|
||||
{
|
||||
lineNumbers: !forcePlainText,
|
||||
mode: initialMode,
|
||||
theme,
|
||||
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||
lineWrapping: false
|
||||
}
|
||||
);
|
||||
window.currentEditor = editor;
|
||||
window.currentEditor = cm;
|
||||
|
||||
setTimeout(adjustEditorSize, 50);
|
||||
observeModalResize(modal);
|
||||
|
||||
// Font controls (now that editor exists)
|
||||
let currentFontSize = 14;
|
||||
const wrapper = editor.getWrapperElement();
|
||||
const wrapper = cm.getWrapperElement();
|
||||
wrapper.style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
cm.refresh();
|
||||
|
||||
decBtn.addEventListener("click", function () {
|
||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||
wrapper.style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
cm.refresh();
|
||||
});
|
||||
incBtn.addEventListener("click", function () {
|
||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||
wrapper.style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
cm.refresh();
|
||||
});
|
||||
|
||||
// Save
|
||||
@@ -496,7 +686,7 @@ export async function editFile(fileName, folder) {
|
||||
// Theme switch
|
||||
function updateEditorTheme() {
|
||||
const isDark = document.body.classList.contains("dark-mode");
|
||||
editor.setOption("theme", isDark ? "material-darker" : "default");
|
||||
cm.setOption("theme", isDark ? "material-darker" : "default");
|
||||
}
|
||||
const toggle = document.getElementById("darkModeToggle");
|
||||
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
||||
@@ -506,12 +696,10 @@ export async function editFile(fileName, folder) {
|
||||
if (!canceled && !forcePlainText) {
|
||||
const nn = normalizeModeName(desiredMode);
|
||||
if (nn && isModeRegistered(nn)) {
|
||||
editor.setOption("mode", desiredMode);
|
||||
cm.setOption("mode", desiredMode);
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
// If the mode truly fails to load, we just stay in plain text
|
||||
});
|
||||
}).catch(() => { /* stay in plain text */ });
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
@@ -182,7 +182,7 @@ function makeBadge(state) {
|
||||
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.background = 'rgba(34,197,94,.15)';
|
||||
el.style.color = '#22c55e';
|
||||
return el;
|
||||
}
|
||||
@@ -191,9 +191,9 @@ function makeBadge(state) {
|
||||
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';
|
||||
el.style.borderColor = 'rgba(234,88,12,.55)';
|
||||
el.style.background = 'rgba(234,88,12,.18)';
|
||||
el.style.color = '#ea580c';
|
||||
return el;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,21 @@ export function openShareModal(file, folder) {
|
||||
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;
|
||||
const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
|
||||
const CODE_RE = /\.(js|mjs|ts|tsx|json|yml|yaml|xml|html?|css|scss|less|php|py|rb|go|rs|c|cpp|h|hpp|java|cs|sh|bat|ps1)$/i;
|
||||
const TXT_RE = /\.(txt|rtf|md|log)$/i;
|
||||
|
||||
function getIconForFile(name) {
|
||||
const lower = (name || '').toLowerCase();
|
||||
if (IMG_RE.test(lower)) return 'image';
|
||||
if (VID_RE.test(lower)) return 'ondemand_video';
|
||||
if (AUD_RE.test(lower)) return 'audiotrack';
|
||||
if (lower.endsWith('.pdf')) return 'picture_as_pdf';
|
||||
if (ARCH_RE.test(lower)) return 'archive';
|
||||
if (CODE_RE.test(lower)) return 'code';
|
||||
if (TXT_RE.test(lower)) return 'description';
|
||||
return 'insert_drive_file';
|
||||
}
|
||||
|
||||
function ensureMediaModal() {
|
||||
let overlay = document.getElementById("filePreviewModal");
|
||||
@@ -152,109 +167,166 @@ function ensureMediaModal() {
|
||||
const navFg = '#fff';
|
||||
const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)';
|
||||
|
||||
// fixed top bar; pad-right to avoid overlap with absolute close “×”
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content media-modal" style="
|
||||
position: relative;
|
||||
max-width: 92vw;
|
||||
max-height: 92vh;
|
||||
width: 92vw;
|
||||
max-height: 92vh;
|
||||
height: 92vh;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
background: ${panelBg};
|
||||
color: ${textCol};
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
display:flex; flex-direction:column;
|
||||
">
|
||||
<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;">
|
||||
<!-- Top bar -->
|
||||
<div class="media-topbar" style="
|
||||
flex:0 0 auto; display:flex; align-items:center; justify-content:space-between;
|
||||
height:44px; padding:6px 12px; padding-right:56px; gap:10px;
|
||||
border-bottom:1px solid ${isDark ? 'rgba(255,255,255,.12)' : 'rgba(0,0,0,.08)'};
|
||||
background:${panelBg};
|
||||
">
|
||||
<div class="media-title" style="display:flex; align-items:center; gap:8px; min-width:0;">
|
||||
<span class="material-icons title-icon" style="
|
||||
width:22px; height:22px; display:inline-flex; align-items:center; justify-content:center;
|
||||
font-size:22px; line-height:1; opacity:${isDark ? '0.96' : '0.9'};">
|
||||
insert_drive_file
|
||||
</span>
|
||||
<div class="title-text" style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
|
||||
</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;">
|
||||
<div class="media-right" style="display:flex; align-items:center; gap:8px;">
|
||||
<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>
|
||||
display:none; padding:4px 8px; border-radius:999px; font-size:12px; line-height:1;
|
||||
border:1px solid transparent; background:transparent; color:inherit;"></span>
|
||||
<div class="action-group" style="display:flex; gap:8px; align-items:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- your absolute close X -->
|
||||
<span id="closeFileModal" class="close-image-modal" title="${t('close')}">×</span>
|
||||
|
||||
<!-- centered media -->
|
||||
<!-- Stage -->
|
||||
<div class="media-stage" style="position:relative; flex:1 1 auto; display:flex; align-items:center; justify-content:center; overflow:hidden;">
|
||||
<div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div>
|
||||
|
||||
<!-- high-contrast prev/next -->
|
||||
<!-- prev/next = rounded rectangles with centered glyphs -->
|
||||
<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;
|
||||
height:56px; min-width:48px; padding:0 14px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:38px; line-height:0;
|
||||
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;
|
||||
height:56px; min-width:48px; padding:0 14px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:38px; line-height:0;
|
||||
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>
|
||||
|
||||
<!-- Absolute close “×” (like original), themed + hover behavior -->
|
||||
<span id="closeFileModal" class="close-image-modal" title="${t('close')}" style="
|
||||
position:absolute; top:8px; right:10px; z-index:1002;
|
||||
width:32px; height:32px; display:inline-flex; align-items:center; justify-content:center;
|
||||
font-size:22px; cursor:pointer; user-select:none; border-radius:50%; transition:all .15s ease;
|
||||
">×</span>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// theme the close “×” for visibility + hover rules that match your site:
|
||||
const closeBtn = overlay.querySelector("#closeFileModal");
|
||||
function paintCloseBase() {
|
||||
closeBtn.style.backgroundColor = 'transparent';
|
||||
closeBtn.style.color = '#e11d48'; // base red X
|
||||
closeBtn.style.boxShadow = 'none';
|
||||
}
|
||||
function onCloseHoverEnter() {
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
closeBtn.style.backgroundColor = '#ef4444'; // red fill
|
||||
closeBtn.style.color = dark ? '#000' : '#fff'; // X: black in dark / white in light
|
||||
closeBtn.style.boxShadow = '0 0 6px rgba(239,68,68,.6)';
|
||||
}
|
||||
function onCloseHoverLeave() { paintCloseBase(); }
|
||||
paintCloseBase();
|
||||
closeBtn.addEventListener('mouseenter', onCloseHoverEnter);
|
||||
closeBtn.addEventListener('mouseleave', onCloseHoverLeave);
|
||||
|
||||
function closeModal() {
|
||||
try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {}
|
||||
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
|
||||
overlay.remove();
|
||||
}
|
||||
overlay.querySelector("#closeFileModal").addEventListener("click", closeModal);
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function setTitle(overlay, name) {
|
||||
const el = overlay.querySelector('.media-title-badge');
|
||||
if (el) el.textContent = name || '';
|
||||
const textEl = overlay.querySelector('.title-text');
|
||||
const iconEl = overlay.querySelector('.title-icon');
|
||||
if (textEl) {
|
||||
textEl.textContent = name || '';
|
||||
textEl.setAttribute('title', name || '');
|
||||
}
|
||||
if (iconEl) {
|
||||
iconEl.textContent = getIconForFile(name);
|
||||
// keep the icon legible in both themes
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
iconEl.style.color = dark ? '#f5f5f5' : '#111111';
|
||||
iconEl.style.opacity = dark ? '0.96' : '0.9';
|
||||
}
|
||||
}
|
||||
|
||||
function makeMI(name, title) {
|
||||
// Topbar icon (theme-aware) used for image tools + video actions
|
||||
function makeTopIcon(name, title) {
|
||||
const b = document.createElement('button');
|
||||
b.className = `material-icons ${name}`;
|
||||
b.textContent = name; // Material Icons font
|
||||
b.className = 'material-icons';
|
||||
b.textContent = name;
|
||||
b.title = title;
|
||||
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
|
||||
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"
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: dark ? '1px solid rgba(255,255,255,.25)' : '1px solid rgba(0,0,0,.15)',
|
||||
background: dark ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '20px',
|
||||
lineHeight: '1',
|
||||
color: dark ? '#f5f5f5' : '#111',
|
||||
boxShadow: dark ? '0 1px 2px rgba(0,0,0,.6)' : '0 1px 1px rgba(0,0,0,.08)'
|
||||
});
|
||||
|
||||
b.addEventListener('mouseenter', () => {
|
||||
const darkNow = document.documentElement.classList.contains('dark-mode');
|
||||
b.style.background = darkNow ? 'rgba(255,255,255,.22)' : 'rgba(0,0,0,.14)';
|
||||
});
|
||||
b.addEventListener('mouseleave', () => {
|
||||
const darkNow = document.documentElement.classList.contains('dark-mode');
|
||||
b.style.background = darkNow ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)';
|
||||
});
|
||||
|
||||
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';
|
||||
prev.style.display = showPrev ? 'flex' : 'none';
|
||||
next.style.display = showNext ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function setRowWatchedBadge(name, watched) {
|
||||
@@ -280,8 +352,8 @@ function setRowWatchedBadge(name, watched) {
|
||||
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");
|
||||
const actionWrap = overlay.querySelector(".media-right .action-group");
|
||||
const statusChip = overlay.querySelector(".media-right .status-chip");
|
||||
|
||||
// replace nav buttons to clear old listeners
|
||||
let prevBtn = overlay.querySelector('.nav-left');
|
||||
@@ -320,10 +392,11 @@ export function previewFile(fileUrl, fileName) {
|
||||
img.dataset.rotate = 0;
|
||||
container.appendChild(img);
|
||||
|
||||
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');
|
||||
// topbar-aligned, theme-aware icons
|
||||
const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
|
||||
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
|
||||
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
|
||||
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
|
||||
actionWrap.appendChild(zoomInBtn);
|
||||
actionWrap.appendChild(zoomOutBtn);
|
||||
actionWrap.appendChild(rotateLeft);
|
||||
@@ -405,14 +478,11 @@ export function previewFile(fileUrl, fileName) {
|
||||
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);
|
||||
// Top-right action icons (Material icons, theme-aware)
|
||||
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||
actionWrap.appendChild(markBtnIcon);
|
||||
actionWrap.appendChild(clearBtnIcon);
|
||||
|
||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||
overlay.mediaType = 'video';
|
||||
@@ -453,15 +523,14 @@ export function previewFile(fileUrl, fileName) {
|
||||
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';
|
||||
markBtnIcon.style.display = 'none';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
// In progress
|
||||
@@ -469,18 +538,20 @@ export function previewFile(fileUrl, fileName) {
|
||||
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';
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
|
||||
statusChip.style.color = ORANGE_HEX;
|
||||
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
|
||||
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
// No progress
|
||||
statusChip.style.display = 'none';
|
||||
markBtn.style.display = '';
|
||||
clearBtn.style.display = 'none';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = 'none';
|
||||
}
|
||||
|
||||
function bindVideoEvents(nm) {
|
||||
@@ -494,8 +565,8 @@ export function previewFile(fileUrl, fileName) {
|
||||
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);
|
||||
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));
|
||||
@@ -528,14 +599,14 @@ setFileProgressBadge(nm, seconds, duration);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
});
|
||||
|
||||
markBtn.onclick = async () => {
|
||||
markBtnIcon.onclick = async () => {
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
};
|
||||
clearBtn.onclick = async () => {
|
||||
clearBtnIcon.onclick = async () => {
|
||||
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("progress_cleared") || "Progress cleared");
|
||||
|
||||
@@ -86,7 +86,7 @@ export function getParentFolder(folder) {
|
||||
Breadcrumb Functions
|
||||
----------------------*/
|
||||
|
||||
function setControlEnabled(el, enabled) {
|
||||
function setControlEnabled(el, enabled) {
|
||||
if (!el) return;
|
||||
if ('disabled' in el) el.disabled = !enabled;
|
||||
el.classList.toggle('disabled', !enabled);
|
||||
@@ -101,7 +101,7 @@ async function applyFolderCapabilities(folder) {
|
||||
const caps = await res.json();
|
||||
window.currentFolderCaps = caps;
|
||||
|
||||
const isRoot = (folder === 'root');
|
||||
const isRoot = (folder === 'root');
|
||||
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
|
||||
setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder);
|
||||
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
|
||||
@@ -143,7 +143,7 @@ function breadcrumbClickHandler(e) {
|
||||
|
||||
updateBreadcrumbTitle(folder);
|
||||
applyFolderCapabilities(folder);
|
||||
expandTreePath(folder);
|
||||
expandTreePath(folder, { persist: false, includeLeaf: false });
|
||||
document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
|
||||
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
||||
if (target) target.classList.add("selected");
|
||||
@@ -184,7 +184,7 @@ function breadcrumbDropHandler(e) {
|
||||
/* FOLDER MOVE FALLBACK */
|
||||
if (!dragData) {
|
||||
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
|
||||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
|
||||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
|
||||
if (plain) {
|
||||
const sourceFolder = String(plain).trim();
|
||||
if (sourceFolder && sourceFolder !== "root") {
|
||||
@@ -208,7 +208,7 @@ function breadcrumbDropHandler(e) {
|
||||
window.currentFolder = newPath;
|
||||
}
|
||||
return loadFolderTree().then(() => {
|
||||
try { expandTreePath(window.currentFolder || "root"); } catch (_) {}
|
||||
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
|
||||
loadFileList(window.currentFolder || "root");
|
||||
});
|
||||
} else {
|
||||
@@ -268,8 +268,8 @@ async function checkUserFolderPermission() {
|
||||
|
||||
const isFolderOnly =
|
||||
!!(permissionsData &&
|
||||
permissionsData[username] &&
|
||||
permissionsData[username].folderOnly);
|
||||
permissionsData[username] &&
|
||||
permissionsData[username].folderOnly);
|
||||
|
||||
window.userFolderOnly = isFolderOnly;
|
||||
localStorage.setItem("folderOnly", isFolderOnly ? "true" : "false");
|
||||
@@ -287,59 +287,217 @@ async function checkUserFolderPermission() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- SVG icons + icon helpers ----------------
|
||||
const _nonEmptyCache = new Map();
|
||||
|
||||
/** Return inline SVG string for either an empty folder or folder-with-paper */
|
||||
/* ----------------------
|
||||
Folder icon (SVG + fetch + cache)
|
||||
----------------------*/
|
||||
|
||||
// Crisp emoji-like folder (empty / with paper)
|
||||
function folderSVG(kind = 'empty') {
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<!-- Angled back body -->
|
||||
<path class="folder-back"
|
||||
d="M3 7.4h7.6l1.6 1.8H20.3c1.1 0 2 .9 2 2v7.6c0 1.1-.9 2-2 2H5
|
||||
c-1.1 0-2-.9-2-2V9.4c0-1.1.9-2 2-2z"/>
|
||||
|
||||
${kind === 'paper'
|
||||
? `
|
||||
<!-- Paper raised so it peeks above the lip -->
|
||||
<rect class="paper" x="6.1" y="5.7" width="11.8" height="10.8" rx="1.2"/>
|
||||
<!-- Bigger fold -->
|
||||
<path class="paper-fold" d="M18.0 5.7h-3.2l3.2 3.2z"/>
|
||||
<!-- Content lines -->
|
||||
<path class="paper-line" d="M7.7 8.2h8.3"/>
|
||||
<path class="paper-line" d="M7.7 9.8h7.2"/>
|
||||
<path class="paper-line" d="M7.7 11.3h6.0"/>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
<!-- Front lip (angled) -->
|
||||
<path class="folder-front"
|
||||
d="M2.3 10.1H10.9l2.0-2.1h7.4c.94 0 1.7.76 1.7 1.7v7.3c0 .94-.76 1.7-1.7 1.7H4
|
||||
c-.94 0-1.7-.76-1.7-1.7v-6.9z"/>
|
||||
|
||||
<!-- Subtle highlight along the lip to add depth -->
|
||||
<path class="lip-highlight"
|
||||
d="M3.3 10.2H11.2l1.7-1.8h7.0"
|
||||
/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const _folderCountCache = new Map();
|
||||
const _inflightCounts = new Map();
|
||||
|
||||
// --- tiny fetch helper with timeout
|
||||
function fetchJSONWithTimeout(url, ms = 3000) {
|
||||
const ctrl = new AbortController();
|
||||
const tid = setTimeout(() => ctrl.abort(), ms);
|
||||
return fetch(url, { credentials: 'include', signal: ctrl.signal })
|
||||
.then(r => r.ok ? r.json() : { folders: 0, files: 0 })
|
||||
.catch(() => ({ folders: 0, files: 0 }))
|
||||
.finally(() => clearTimeout(tid));
|
||||
}
|
||||
|
||||
// --- simple concurrency limiter (prevents 100 simultaneous requests)
|
||||
const MAX_CONCURRENT_COUNT_REQS = 6;
|
||||
let _activeCountReqs = 0;
|
||||
const _countReqQueue = [];
|
||||
|
||||
function _runCount(url) {
|
||||
return new Promise(resolve => {
|
||||
const start = () => {
|
||||
_activeCountReqs++;
|
||||
fetchJSONWithTimeout(url, 2500)
|
||||
.then(resolve)
|
||||
.finally(() => {
|
||||
_activeCountReqs--;
|
||||
const next = _countReqQueue.shift();
|
||||
if (next) next();
|
||||
});
|
||||
};
|
||||
if (_activeCountReqs < MAX_CONCURRENT_COUNT_REQS) start();
|
||||
else _countReqQueue.push(start);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchFolderCounts(folder) {
|
||||
if (_folderCountCache.has(folder)) return _folderCountCache.get(folder);
|
||||
if (_inflightCounts.has(folder)) return _inflightCounts.get(folder);
|
||||
|
||||
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}`;
|
||||
const p = _runCount(url).then(data => {
|
||||
const result = {
|
||||
folders: Number(data?.folders || 0),
|
||||
files: Number(data?.files || 0),
|
||||
};
|
||||
_folderCountCache.set(folder, result);
|
||||
_inflightCounts.delete(folder);
|
||||
return result;
|
||||
});
|
||||
|
||||
_inflightCounts.set(folder, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function setFolderIconForOption(optEl, kind) {
|
||||
const iconEl = optEl.querySelector('.folder-icon');
|
||||
if (!iconEl) return;
|
||||
iconEl.dataset.kind = kind;
|
||||
iconEl.innerHTML = folderSVG(kind);
|
||||
}
|
||||
|
||||
function ensureFolderIcon(folder) {
|
||||
const opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
|
||||
if (!opt) return;
|
||||
// Set a neutral default first so layout is stable
|
||||
setFolderIconForOption(opt, 'empty');
|
||||
|
||||
fetchFolderCounts(folder).then(({ folders, files }) => {
|
||||
setFolderIconForOption(opt, (folders + files) > 0 ? 'paper' : 'empty');
|
||||
});
|
||||
}
|
||||
/** Set a folder row’s icon to 'empty' or 'paper' */
|
||||
function setFolderIcon(folderPath, kind) {
|
||||
const iconEl = document.querySelector(`.folder-option[data-folder="${folderPath}"] .folder-icon`);
|
||||
if (!iconEl) return;
|
||||
if (iconEl.dataset.icon === kind) return;
|
||||
iconEl.dataset.icon = kind;
|
||||
iconEl.innerHTML = folderSVG(kind);
|
||||
}
|
||||
|
||||
/** Fast local heuristic: mark 'paper' if we can see any subfolders under this LI */
|
||||
function markNonEmptyIfHasChildren(folderPath) {
|
||||
const option = document.querySelector(`.folder-option[data-folder="${folderPath}"]`);
|
||||
if (!option) return false;
|
||||
const li = option.closest('li[role="treeitem"]');
|
||||
const childUL = li ? li.querySelector(':scope > ul') : null;
|
||||
const hasChildNodes = !!(childUL && childUL.querySelector('li'));
|
||||
if (hasChildNodes) { setFolderIcon(folderPath, 'paper'); _nonEmptyCache.set(folderPath, true); }
|
||||
return hasChildNodes;
|
||||
}
|
||||
|
||||
/** ACL-aware check for files: call a tiny stats endpoint (see part C) */
|
||||
async function fetchFolderNonEmptyACL(folderPath) {
|
||||
if (_nonEmptyCache.has(folderPath)) return _nonEmptyCache.get(folderPath);
|
||||
const { folders, files } = await fetchFolderCounts(folderPath);
|
||||
const nonEmpty = (folders + files) > 0;
|
||||
_nonEmptyCache.set(folderPath, nonEmpty);
|
||||
return nonEmpty;
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------
|
||||
DOM Building Functions for Folder Tree
|
||||
----------------------*/
|
||||
function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
||||
const state = loadFolderTreeState();
|
||||
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
|
||||
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}" role="group">`;
|
||||
|
||||
for (const folder in tree) {
|
||||
const name = folder.toLowerCase();
|
||||
if (name === "trash" || name === "profile_pics") continue;
|
||||
|
||||
const fullPath = parentPath ? parentPath + "/" + folder : folder;
|
||||
const hasChildren = Object.keys(tree[folder]).length > 0;
|
||||
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
|
||||
html += `<li class="folder-item">`;
|
||||
const isOpen = displayState !== 'none';
|
||||
|
||||
html += `<li class="folder-item" role="treeitem" aria-expanded="${hasChildren ? String(isOpen) : 'false'}">`;
|
||||
|
||||
html += `<div class="folder-row">`;
|
||||
if (hasChildren) {
|
||||
const toggleSymbol = (displayState === 'none') ? '[+]' : '[' + '<span class="custom-dash">-</span>' + ']';
|
||||
html += `<span class="folder-toggle" data-folder="${fullPath}">${toggleSymbol}</span>`;
|
||||
html += `<button type="button" class="folder-toggle" aria-label="${isOpen ? 'Collapse' : 'Expand'}" data-folder="${fullPath}"></button>`;
|
||||
} else {
|
||||
html += `<span class="folder-indent-placeholder"></span>`;
|
||||
}
|
||||
html += `<span class="folder-option" draggable="true" data-folder="${fullPath}">${escapeHTML(folder)}</span>`;
|
||||
if (hasChildren) {
|
||||
html += renderFolderTree(tree[folder], fullPath, displayState);
|
||||
html += `<span class="folder-spacer" aria-hidden="true"></span>`;
|
||||
}
|
||||
html += `
|
||||
<span class="folder-option" draggable="true" data-folder="${fullPath}">
|
||||
<span class="folder-icon" aria-hidden="true" data-icon="${hasChildren ? 'paper' : 'empty'}">
|
||||
${folderSVG(hasChildren ? 'paper' : 'empty')}
|
||||
</span>
|
||||
<span class="folder-label">${escapeHTML(folder)}</span>
|
||||
</span>
|
||||
`;
|
||||
html += `</div>`; // /.folder-row
|
||||
|
||||
if (hasChildren) html += renderFolderTree(tree[folder], fullPath, displayState);
|
||||
html += `</li>`;
|
||||
}
|
||||
|
||||
html += `</ul>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function expandTreePath(path) {
|
||||
const parts = path.split("/");
|
||||
let cumulative = "";
|
||||
parts.forEach((part, index) => {
|
||||
cumulative = index === 0 ? part : cumulative + "/" + part;
|
||||
const option = document.querySelector(`.folder-option[data-folder="${cumulative}"]`);
|
||||
if (option) {
|
||||
const li = option.parentNode;
|
||||
const nestedUl = li.querySelector("ul");
|
||||
if (nestedUl && (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded"))) {
|
||||
nestedUl.classList.remove("collapsed");
|
||||
nestedUl.classList.add("expanded");
|
||||
const toggle = li.querySelector(".folder-toggle");
|
||||
if (toggle) {
|
||||
toggle.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
const state = loadFolderTreeState();
|
||||
state[cumulative] = "block";
|
||||
saveFolderTreeState(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
// replace your current expandTreePath with this version
|
||||
function expandTreePath(path, opts = {}) {
|
||||
const { force = false } = opts;
|
||||
const state = loadFolderTreeState();
|
||||
const parts = (path || '').split('/').filter(Boolean);
|
||||
let cumulative = '';
|
||||
|
||||
parts.forEach((part, i) => {
|
||||
cumulative = i === 0 ? part : `${cumulative}/${part}`;
|
||||
const option = document.querySelector(`.folder-option[data-folder="${CSS.escape(cumulative)}"]`);
|
||||
if (!option) return;
|
||||
|
||||
const li = option.closest('li[role="treeitem"]');
|
||||
const nestedUl = li ? li.querySelector(':scope > ul') : null;
|
||||
if (!nestedUl) return;
|
||||
|
||||
// Only expand if caller forces it OR saved state says "block"
|
||||
const shouldExpand = force || state[cumulative] === 'block';
|
||||
nestedUl.classList.toggle('expanded', shouldExpand);
|
||||
nestedUl.classList.toggle('collapsed', !shouldExpand);
|
||||
li.setAttribute('aria-expanded', String(!!shouldExpand));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------
|
||||
Drag & Drop Support for Folder Tree Nodes
|
||||
----------------------*/
|
||||
@@ -360,15 +518,15 @@ function folderDropHandler(event) {
|
||||
try {
|
||||
const jsonStr = event.dataTransfer.getData("application/json") || "";
|
||||
if (jsonStr) dragData = JSON.parse(jsonStr);
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Invalid drag data", e);
|
||||
return;
|
||||
}
|
||||
/* FOLDER MOVE FALLBACK */
|
||||
if (!dragData) {
|
||||
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
|
||||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
|
||||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
|
||||
if (plain) {
|
||||
const sourceFolder = String(plain).trim();
|
||||
if (sourceFolder && sourceFolder !== "root") {
|
||||
@@ -392,7 +550,7 @@ function folderDropHandler(event) {
|
||||
window.currentFolder = newPath;
|
||||
}
|
||||
return loadFolderTree().then(() => {
|
||||
try { expandTreePath(window.currentFolder || "root"); } catch (_) {}
|
||||
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
|
||||
loadFileList(window.currentFolder || "root");
|
||||
});
|
||||
} else {
|
||||
@@ -442,22 +600,29 @@ function folderDropHandler(event) {
|
||||
// Safe breadcrumb DOM builder
|
||||
function renderBreadcrumbFragment(folderPath) {
|
||||
const frag = document.createDocumentFragment();
|
||||
const parts = folderPath.split("/");
|
||||
let acc = "";
|
||||
|
||||
parts.forEach((part, idx) => {
|
||||
acc = idx === 0 ? part : acc + "/" + part;
|
||||
// Defensive normalize
|
||||
const path = (typeof folderPath === 'string' && folderPath.length) ? folderPath : 'root';
|
||||
const crumbs = path.split('/').filter(s => s !== ''); // no empty segments
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("breadcrumb-link");
|
||||
let acc = '';
|
||||
for (let i = 0; i < crumbs.length; i++) {
|
||||
const part = crumbs[i];
|
||||
acc = (i === 0) ? part : (acc + '/' + part);
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = 'breadcrumb-link';
|
||||
span.dataset.folder = acc;
|
||||
span.textContent = part;
|
||||
frag.appendChild(span);
|
||||
|
||||
if (idx < parts.length - 1) {
|
||||
frag.appendChild(document.createTextNode(" / "));
|
||||
if (i < crumbs.length - 1) {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'file-breadcrumb-sep';
|
||||
sep.textContent = '›';
|
||||
frag.appendChild(sep);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return frag;
|
||||
}
|
||||
@@ -536,23 +701,61 @@ export async function loadFolderTree(selectedFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<div id="rootRow" class="root-row">
|
||||
<span class="folder-toggle" data-folder="${effectiveRoot}">[<span class="custom-dash">-</span>]</span>
|
||||
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">${effectiveLabel}</span>
|
||||
</div>`;
|
||||
const state0 = loadFolderTreeState();
|
||||
const rootOpen = state0[effectiveRoot] !== 'none';
|
||||
|
||||
let html = `
|
||||
<div id="rootRow" class="folder-row" role="treeitem" aria-expanded="${String(rootOpen)}">
|
||||
<button type="button" class="folder-toggle" data-folder="${effectiveRoot}" aria-label="${rootOpen ? 'Collapse' : 'Expand'}"></button>
|
||||
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">
|
||||
<span class="folder-icon" aria-hidden="true"></span>
|
||||
<span class="folder-label">${effectiveLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (folders.length > 0) {
|
||||
const tree = buildFolderTree(folders);
|
||||
html += renderFolderTree(tree, "", "block");
|
||||
// 👇 pass the root's saved state down to first level
|
||||
html += renderFolderTree(tree, "", rootOpen ? "block" : "none");
|
||||
}
|
||||
container.innerHTML = html;
|
||||
|
||||
const st = loadFolderTreeState();
|
||||
const rootUl = container.querySelector('#rootRow + ul');
|
||||
if (rootUl) {
|
||||
const expanded = (st[effectiveRoot] ?? 'block') === 'block';
|
||||
rootUl.classList.toggle('expanded', expanded);
|
||||
rootUl.classList.toggle('collapsed', !expanded);
|
||||
const rr = container.querySelector('#rootRow');
|
||||
if (rr) rr.setAttribute('aria-expanded', String(expanded));
|
||||
}
|
||||
|
||||
// Prime icons for everything visible
|
||||
primeFolderIcons(container);
|
||||
|
||||
function primeFolderIcons(scopeEl) {
|
||||
const opts = scopeEl.querySelectorAll('.folder-option[data-folder]');
|
||||
opts.forEach(opt => {
|
||||
const f = opt.getAttribute('data-folder');
|
||||
// Optional: if there are obvious children in DOM, show 'paper' immediately as a hint
|
||||
const li = opt.closest('li[role="treeitem"]');
|
||||
const hasChildren = !!(li && li.querySelector(':scope > ul > li'));
|
||||
setFolderIconForOption(opt, hasChildren ? 'paper' : 'empty');
|
||||
// Then confirm with server (files count)
|
||||
ensureFolderIcon(f);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach drag/drop event listeners.
|
||||
container.querySelectorAll(".folder-option").forEach(el => {
|
||||
const fp = el.getAttribute('data-folder');
|
||||
markNonEmptyIfHasChildren(fp);
|
||||
// Provide folder path payload for folder->folder DnD
|
||||
el.addEventListener("dragstart", (ev) => {
|
||||
const src = el.getAttribute("data-folder");
|
||||
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {}
|
||||
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {}
|
||||
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) { }
|
||||
try { ev.dataTransfer.setData("text/plain", src); } catch (e) { }
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
|
||||
@@ -569,11 +772,12 @@ export async function loadFolderTree(selectedFolder) {
|
||||
// Initial breadcrumb + file list
|
||||
updateBreadcrumbTitle(window.currentFolder);
|
||||
applyFolderCapabilities(window.currentFolder);
|
||||
ensureFolderIcon(window.currentFolder);
|
||||
loadFileList(window.currentFolder);
|
||||
|
||||
const folderState = loadFolderTreeState();
|
||||
if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") {
|
||||
expandTreePath(window.currentFolder);
|
||||
// Show ancestors so the current selection is visible, but don't persist
|
||||
if (window.currentFolder && window.currentFolder !== effectiveRoot) {
|
||||
expandTreePath(window.currentFolder, { persist: false, includeLeaf: false });
|
||||
}
|
||||
|
||||
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`);
|
||||
@@ -587,8 +791,8 @@ export async function loadFolderTree(selectedFolder) {
|
||||
// Provide folder path payload for folder->folder DnD
|
||||
el.addEventListener("dragstart", (ev) => {
|
||||
const src = el.getAttribute("data-folder");
|
||||
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {}
|
||||
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {}
|
||||
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) { }
|
||||
try { ev.dataTransfer.setData("text/plain", src); } catch (e) { }
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
|
||||
@@ -602,55 +806,48 @@ export async function loadFolderTree(selectedFolder) {
|
||||
|
||||
updateBreadcrumbTitle(selected);
|
||||
applyFolderCapabilities(selected);
|
||||
ensureFolderIcon(selected);
|
||||
loadFileList(selected);
|
||||
});
|
||||
});
|
||||
|
||||
// Root toggle handler
|
||||
// Root toggle
|
||||
const rootToggle = container.querySelector("#rootRow .folder-toggle");
|
||||
if (rootToggle) {
|
||||
rootToggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
const nestedUl = container.querySelector("#rootRow + ul");
|
||||
if (nestedUl) {
|
||||
const state = loadFolderTreeState();
|
||||
if (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded")) {
|
||||
nestedUl.classList.remove("collapsed");
|
||||
nestedUl.classList.add("expanded");
|
||||
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
state[effectiveRoot] = "block";
|
||||
} else {
|
||||
nestedUl.classList.remove("expanded");
|
||||
nestedUl.classList.add("collapsed");
|
||||
this.textContent = "[+]";
|
||||
state[effectiveRoot] = "none";
|
||||
}
|
||||
saveFolderTreeState(state);
|
||||
}
|
||||
if (!nestedUl) return;
|
||||
|
||||
const state = loadFolderTreeState();
|
||||
const expanded = !(nestedUl.classList.contains("expanded"));
|
||||
nestedUl.classList.toggle("expanded", expanded);
|
||||
nestedUl.classList.toggle("collapsed", !expanded);
|
||||
|
||||
document.getElementById("rootRow").setAttribute("aria-expanded", String(expanded));
|
||||
state[effectiveRoot] = expanded ? "block" : "none";
|
||||
saveFolderTreeState(state);
|
||||
});
|
||||
}
|
||||
|
||||
// Other folder-toggle handlers
|
||||
container.querySelectorAll(".folder-toggle").forEach(toggle => {
|
||||
// Other toggles
|
||||
|
||||
container.querySelectorAll("button.folder-toggle").forEach(toggle => {
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
const siblingUl = this.parentNode.querySelector("ul");
|
||||
const li = this.closest('li[role="treeitem"]');
|
||||
const siblingUl = li ? li.querySelector(':scope > ul') : null;
|
||||
const folderPath = this.getAttribute("data-folder");
|
||||
if (!siblingUl) return;
|
||||
|
||||
const state = loadFolderTreeState();
|
||||
if (siblingUl) {
|
||||
if (siblingUl.classList.contains("collapsed") || !siblingUl.classList.contains("expanded")) {
|
||||
siblingUl.classList.remove("collapsed");
|
||||
siblingUl.classList.add("expanded");
|
||||
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
state[folderPath] = "block";
|
||||
} else {
|
||||
siblingUl.classList.remove("expanded");
|
||||
siblingUl.classList.add("collapsed");
|
||||
this.textContent = "[+]";
|
||||
state[folderPath] = "none";
|
||||
}
|
||||
saveFolderTreeState(state);
|
||||
}
|
||||
const expanded = !(siblingUl.classList.contains("expanded"));
|
||||
siblingUl.classList.toggle("expanded", expanded);
|
||||
siblingUl.classList.toggle("collapsed", !expanded);
|
||||
li.setAttribute("aria-expanded", String(expanded));
|
||||
state[folderPath] = expanded ? "block" : "none";
|
||||
saveFolderTreeState(state);
|
||||
ensureFolderIcon(folderPath);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -749,7 +946,7 @@ if (submitRename) {
|
||||
|
||||
// === Move Folder Modal helper (shared by button + context menu) ===
|
||||
function openMoveFolderUI(sourceFolder) {
|
||||
const modal = document.getElementById('moveFolderModal');
|
||||
const modal = document.getElementById('moveFolderModal');
|
||||
const targetSel = document.getElementById('moveFolderTarget');
|
||||
|
||||
// If you right-clicked a different folder than currently selected, use that
|
||||
@@ -779,7 +976,7 @@ function openMoveFolderUI(sourceFolder) {
|
||||
targetSel.appendChild(o);
|
||||
});
|
||||
})
|
||||
.catch(()=>{ /* no-op */ });
|
||||
.catch(() => { /* no-op */ });
|
||||
}
|
||||
|
||||
if (modal) modal.style.display = 'block';
|
||||
@@ -1073,11 +1270,11 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
bindFolderManagerContextMenu();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const moveBtn = document.getElementById('moveFolderBtn');
|
||||
const modal = document.getElementById('moveFolderModal');
|
||||
const moveBtn = document.getElementById('moveFolderBtn');
|
||||
const modal = document.getElementById('moveFolderModal');
|
||||
const targetSel = document.getElementById('moveFolderTarget');
|
||||
const cancelBtn = document.getElementById('cancelMoveFolder');
|
||||
const confirmBtn= document.getElementById('confirmMoveFolder');
|
||||
const confirmBtn = document.getElementById('confirmMoveFolder');
|
||||
|
||||
if (moveBtn) {
|
||||
moveBtn.addEventListener('click', () => {
|
||||
@@ -1092,7 +1289,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (confirmBtn) confirmBtn.addEventListener('click', async () => {
|
||||
if (!targetSel) return;
|
||||
const destination = targetSel.value;
|
||||
const source = window.currentFolder;
|
||||
const source = window.currentFolder;
|
||||
|
||||
if (!destination) { showToast('Pick a destination'); return; }
|
||||
if (destination === source || (destination + '/').startsWith(source + '/')) {
|
||||
@@ -1108,7 +1305,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const data = await safeJson(res);
|
||||
if (res.ok && data && !data.error) {
|
||||
showToast('Folder moved');
|
||||
if (modal) modal.style.display='none';
|
||||
if (modal) modal.style.display = 'none';
|
||||
await loadFolderTree();
|
||||
const base = source.split('/').pop();
|
||||
const newPath = (destination === 'root' ? '' : destination + '/') + base;
|
||||
|
||||
@@ -403,39 +403,57 @@ function bindDarkMode() {
|
||||
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;
|
||||
|
||||
|
||||
// --- 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 disableBasic = !!lo.disableBasicAuth;
|
||||
|
||||
const row = $('#loginForm');
|
||||
if (row) {
|
||||
if (disableForm) {
|
||||
row.setAttribute('hidden', '');
|
||||
row.style.display = ''; // don't leave display:none lying around
|
||||
|
||||
|
||||
// be tolerant to key variants just in case
|
||||
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
|
||||
|
||||
const showForm = !disableForm;
|
||||
const showOIDC = !disableOIDC;
|
||||
const showBasic = !disableBasic;
|
||||
|
||||
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
|
||||
const authForm = $('#authForm'); // inner username/password form
|
||||
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
|
||||
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||
|
||||
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
|
||||
if (loginWrap) {
|
||||
const anyMethod = showForm || showOIDC || showBasic;
|
||||
if (anyMethod) {
|
||||
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
|
||||
loginWrap.style.display = ''; // let CSS decide
|
||||
} else {
|
||||
row.removeAttribute('hidden');
|
||||
row.style.display = '';
|
||||
loginWrap.setAttribute('hidden', '');
|
||||
loginWrap.style.display = '';
|
||||
}
|
||||
}
|
||||
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
||||
|
||||
// 2) Toggle the pieces inside the wrapper
|
||||
if (authForm) authForm.style.display = showForm ? '' : 'none';
|
||||
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
|
||||
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
|
||||
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
||||
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
||||
|
||||
|
||||
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
|
||||
if (phase === 'final') {
|
||||
const h1 = document.querySelector('.header-title h1');
|
||||
if (h1) {
|
||||
// prevent i18n or legacy from overwriting it
|
||||
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
|
||||
|
||||
|
||||
if (h1.textContent !== title) h1.textContent = title;
|
||||
|
||||
|
||||
// lock it so late code can't stomp it
|
||||
if (!h1.__titleLock) {
|
||||
const mo = new MutationObserver(() => {
|
||||
@@ -1037,6 +1055,21 @@ function bindDarkMode() {
|
||||
if (login) login.style.display = '';
|
||||
// …wire stuff…
|
||||
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
|
||||
// Auto-SSO if OIDC is the only enabled method (add ?noauto=1 to skip)
|
||||
(() => {
|
||||
const lo = (window.__FR_SITE_CFG__ && window.__FR_SITE_CFG__.loginOptions) || {};
|
||||
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
|
||||
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||
|
||||
const onlyOIDC = disableForm && disableBasic && !disableOIDC;
|
||||
const qp = new URLSearchParams(location.search);
|
||||
|
||||
if (onlyOIDC && qp.get('noauto') !== '1') {
|
||||
const btn = document.getElementById('oidcLoginBtn');
|
||||
if (btn) setTimeout(() => btn.click(), 250);
|
||||
}
|
||||
})();
|
||||
await revealAppAndHideOverlay();
|
||||
const hb = document.querySelector('.header-buttons');
|
||||
if (hb) hb.style.visibility = 'hidden';
|
||||
@@ -1102,7 +1135,7 @@ function bindDarkMode() {
|
||||
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(() => {});
|
||||
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => { });
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v1.8.5';
|
||||
window.APP_VERSION = 'v1.9.0';
|
||||
|
||||
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 623 KiB After Width: | Height: | Size: 618 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 687 KiB After Width: | Height: | Size: 687 KiB |
BIN
resources/filerise-v1.8.10-latest.gif
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 552 KiB After Width: | Height: | Size: 546 KiB |
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 788 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 608 KiB After Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 538 KiB After Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 610 KiB After Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 554 KiB After Width: | Height: | Size: 666 KiB |
179
src/cli/zip_worker.php
Normal file
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../../config/config.php';
|
||||
require __DIR__ . '/../../src/models/FileModel.php';
|
||||
|
||||
$token = $argv[1] ?? '';
|
||||
$token = preg_replace('/[^a-f0-9]/','',$token);
|
||||
if ($token === '') { fwrite(STDERR, "No token\n"); exit(1); }
|
||||
|
||||
$root = rtrim((string)META_DIR, '/\\') . '/ziptmp';
|
||||
$tokDir = $root . '/.tokens';
|
||||
$logDir = $root . '/.logs';
|
||||
@mkdir($tokDir, 0775, true);
|
||||
@mkdir($logDir, 0775, true);
|
||||
|
||||
$tokFile = $tokDir . '/' . $token . '.json';
|
||||
$logFile = $logDir . '/WORKER-' . $token . '.log';
|
||||
|
||||
file_put_contents($logFile, "[".date('c')."] worker start token={$token}\n", FILE_APPEND);
|
||||
|
||||
// Keep libzip temp files on same FS as final zip (prevents cross-device rename failures)
|
||||
@mkdir($root, 0775, true);
|
||||
@putenv('TMPDIR='.$root);
|
||||
@ini_set('sys_temp_dir', $root);
|
||||
|
||||
// Small janitor: purge old tokens/logs (> 6h)
|
||||
$now = time();
|
||||
foreach (glob($tokDir.'/*.json') ?: [] as $f) { if (is_file($f) && ($now - @filemtime($f)) > 21600) @unlink($f); }
|
||||
foreach (glob($logDir.'/WORKER-*.log') ?: [] as $f) { if (is_file($f) && ($now - @filemtime($f)) > 21600) @unlink($f); }
|
||||
|
||||
// Helpers to read/write the token file safely
|
||||
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
|
||||
|
||||
$save = function() use (&$job, $tokFile) {
|
||||
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
@clearstatcache(true, $tokFile);
|
||||
};
|
||||
|
||||
$touchPhase = function(string $phase) use (&$job, $save) {
|
||||
$job['phase'] = $phase;
|
||||
$save();
|
||||
};
|
||||
|
||||
// Init timing
|
||||
if (empty($job['startedAt'])) {
|
||||
$job['startedAt'] = time();
|
||||
}
|
||||
$job['status'] = 'working';
|
||||
$job['error'] = null;
|
||||
$save();
|
||||
|
||||
// Build the list of files to zip using the model (same validation FileRise uses)
|
||||
try {
|
||||
// Reuse FileModel’s validation by calling it but not keeping the zip; we’ll enumerate sizes here.
|
||||
$folder = (string)($job['folder'] ?? 'root');
|
||||
$names = (array)($job['files'] ?? []);
|
||||
|
||||
// Resolve folder path similarly to createZipArchive
|
||||
$baseDir = realpath(UPLOAD_DIR);
|
||||
if ($baseDir === false) {
|
||||
throw new RuntimeException('Uploads directory not configured correctly.');
|
||||
}
|
||||
if (strtolower($folder) === 'root' || $folder === "") {
|
||||
$folderPathReal = $baseDir;
|
||||
} else {
|
||||
if (strpos($folder, '..') !== false) throw new RuntimeException('Invalid folder name.');
|
||||
$parts = explode('/', trim($folder, "/\\ "));
|
||||
foreach ($parts as $part) {
|
||||
if ($part === '' || !preg_match(REGEX_FOLDER_NAME, $part)) {
|
||||
throw new RuntimeException('Invalid folder name.');
|
||||
}
|
||||
}
|
||||
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||
$folderPathReal = realpath($folderPath);
|
||||
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
|
||||
throw new RuntimeException('Folder not found.');
|
||||
}
|
||||
}
|
||||
|
||||
// Collect files (only regular files)
|
||||
$filesToZip = [];
|
||||
foreach ($names as $nm) {
|
||||
$bn = basename(trim((string)$nm));
|
||||
if (!preg_match(REGEX_FILE_NAME, $bn)) continue;
|
||||
$fp = $folderPathReal . DIRECTORY_SEPARATOR . $bn;
|
||||
if (is_file($fp)) $filesToZip[] = $fp;
|
||||
}
|
||||
if (!$filesToZip) throw new RuntimeException('No valid files to zip.');
|
||||
|
||||
// Totals for progress
|
||||
$filesTotal = count($filesToZip);
|
||||
$bytesTotal = 0;
|
||||
foreach ($filesToZip as $fp) {
|
||||
$sz = @filesize($fp);
|
||||
if ($sz !== false) $bytesTotal += (int)$sz;
|
||||
}
|
||||
|
||||
$job['filesTotal'] = $filesTotal;
|
||||
$job['bytesTotal'] = $bytesTotal;
|
||||
$job['filesDone'] = 0;
|
||||
$job['bytesDone'] = 0;
|
||||
$job['pct'] = 0;
|
||||
$job['current'] = null;
|
||||
$job['phase'] = 'zipping';
|
||||
$save();
|
||||
|
||||
// Create final zip path in META_DIR/ziptmp
|
||||
$zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
|
||||
$zipPath = $root . DIRECTORY_SEPARATOR . $zipName;
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new RuntimeException('Could not create zip archive.');
|
||||
}
|
||||
|
||||
// Enumerate files; report up to 98%
|
||||
$bytesDone = 0;
|
||||
$filesDone = 0;
|
||||
foreach ($filesToZip as $fp) {
|
||||
$bn = basename($fp);
|
||||
$zip->addFile($fp, $bn);
|
||||
|
||||
$filesDone++;
|
||||
$sz = @filesize($fp);
|
||||
if ($sz !== false) $bytesDone += (int)$sz;
|
||||
|
||||
$job['filesDone'] = $filesDone;
|
||||
$job['bytesDone'] = $bytesDone;
|
||||
$job['current'] = $bn;
|
||||
|
||||
$pct = ($bytesTotal > 0) ? (int) floor(($bytesDone / $bytesTotal) * 98) : 0;
|
||||
if ($pct < 0) $pct = 0;
|
||||
if ($pct > 98) $pct = 98;
|
||||
if ($pct > (int)($job['pct'] ?? 0)) $job['pct'] = $pct;
|
||||
|
||||
$save();
|
||||
}
|
||||
|
||||
// Finalizing (this is where libzip writes & renames)
|
||||
$job['pct'] = max((int)($job['pct'] ?? 0), 99);
|
||||
$job['phase'] = 'finalizing';
|
||||
$job['finalizeAt'] = time();
|
||||
|
||||
// Publish selected totals for a truthful UI during finalizing,
|
||||
// and clear incremental fields so the UI doesn't show "7/7 14 GB / 14 GB" prematurely.
|
||||
$job['selectedFiles'] = $filesTotal;
|
||||
$job['selectedBytes'] = $bytesTotal;
|
||||
$job['filesDone'] = null;
|
||||
$job['bytesDone'] = null;
|
||||
$job['current'] = null;
|
||||
|
||||
$save();
|
||||
|
||||
// ---- finalize the zip on disk ----
|
||||
$ok = $zip->close();
|
||||
$statusStr = method_exists($zip, 'getStatusString') ? $zip->getStatusString() : '';
|
||||
|
||||
if (!$ok || !is_file($zipPath)) {
|
||||
$job['status'] = 'error';
|
||||
$job['error'] = 'Failed to finalize ZIP' . ($statusStr ? " ($statusStr)" : '');
|
||||
$save();
|
||||
file_put_contents($logFile, "[".date('c')."] error: ".$job['error']."\n", FILE_APPEND);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$job['status'] = 'done';
|
||||
$job['zipPath'] = $zipPath;
|
||||
$job['pct'] = 100;
|
||||
$job['phase'] = 'finalized';
|
||||
$save();
|
||||
file_put_contents($logFile, "[".date('c')."] done zip={$zipPath}\n", FILE_APPEND);
|
||||
} catch (Throwable $e) {
|
||||
$job['status'] = 'error';
|
||||
$job['error'] = 'Worker exception: '.$e->getMessage();
|
||||
$save();
|
||||
file_put_contents($logFile, "[".date('c')."] exception: ".$e->getMessage()."\n", FILE_APPEND);
|
||||
}
|
||||
@@ -57,12 +57,26 @@ class AuthController
|
||||
$oidcAction = 'callback';
|
||||
}
|
||||
if ($oidcAction) {
|
||||
$cfg = AdminModel::getConfig();
|
||||
$cfg = AdminModel::getConfig();
|
||||
$clientId = $cfg['oidc']['clientId'] ?? null;
|
||||
$clientSecret = $cfg['oidc']['clientSecret'] ?? null;
|
||||
// When configured as a public client (no secret), pass null, not an empty string.
|
||||
if ($clientSecret === '') { $clientSecret = null; }
|
||||
|
||||
$oidc = new OpenIDConnectClient(
|
||||
$cfg['oidc']['providerUrl'],
|
||||
$cfg['oidc']['clientId'],
|
||||
$cfg['oidc']['clientSecret']
|
||||
$clientId ?: null,
|
||||
$clientSecret
|
||||
);
|
||||
|
||||
// Always send PKCE (S256). Required by Authelia for public clients, safe for confidential ones.
|
||||
if (method_exists($oidc, 'setCodeChallengeMethod')) {
|
||||
$oidc->setCodeChallengeMethod('S256');
|
||||
}
|
||||
// client_secret_post with Authelia using config.php
|
||||
if (method_exists($oidc, 'setTokenEndpointAuthMethod') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
|
||||
$oidc->setTokenEndpointAuthMethod(OIDC_TOKEN_ENDPOINT_AUTH_METHOD);
|
||||
}
|
||||
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
|
||||
$oidc->addScope(['openid','profile','email']);
|
||||
|
||||
|
||||
@@ -190,6 +190,59 @@ class FileController
|
||||
return $ok ? null : "Forbidden: folder scope violation.";
|
||||
}
|
||||
|
||||
private function spawnZipWorker(string $token, string $tokFile, string $logDir): array
|
||||
{
|
||||
$worker = realpath(PROJECT_ROOT . '/src/cli/zip_worker.php');
|
||||
if (!$worker || !is_file($worker)) {
|
||||
return ['ok'=>false, 'error'=>'zip_worker.php not found'];
|
||||
}
|
||||
|
||||
// Find a PHP CLI binary that actually works
|
||||
$candidates = array_values(array_filter([
|
||||
PHP_BINARY ?: null,
|
||||
'/usr/local/bin/php',
|
||||
'/usr/bin/php',
|
||||
'/bin/php'
|
||||
]));
|
||||
$php = null;
|
||||
foreach ($candidates as $bin) {
|
||||
if (!$bin) continue;
|
||||
$rc = 1;
|
||||
@exec(escapeshellcmd($bin).' -v >/dev/null 2>&1', $o, $rc);
|
||||
if ($rc === 0) { $php = $bin; break; }
|
||||
}
|
||||
if (!$php) {
|
||||
return ['ok'=>false, 'error'=>'No working php CLI found'];
|
||||
}
|
||||
|
||||
$logFile = $logDir . DIRECTORY_SEPARATOR . 'WORKER-' . $token . '.log';
|
||||
|
||||
// Ensure TMPDIR is on the same FS as the final zip; actually apply it to the child process.
|
||||
$tmpDir = rtrim((string)META_DIR, '/\\') . '/ziptmp';
|
||||
@mkdir($tmpDir, 0775, true);
|
||||
|
||||
// Build one sh -c string so env + nohup + echo $! are in the same shell
|
||||
$cmdStr =
|
||||
'export TMPDIR=' . escapeshellarg($tmpDir) . ' ; ' .
|
||||
'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) . ' ' . escapeshellarg($token) .
|
||||
' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
|
||||
|
||||
$pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
|
||||
$pid = is_string($pid) ? (int)trim($pid) : 0;
|
||||
|
||||
// Persist spawn metadata into token (best-effort)
|
||||
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
|
||||
$job['spawn'] = [
|
||||
'ts' => time(),
|
||||
'php' => $php,
|
||||
'pid' => $pid,
|
||||
'log' => $logFile
|
||||
];
|
||||
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
|
||||
return $pid > 0 ? ['ok'=>true] : ['ok'=>false, 'error'=>'spawn returned no PID'];
|
||||
}
|
||||
|
||||
// --- small helpers ---
|
||||
private function _jsonStart(): void {
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
@@ -665,78 +718,214 @@ public function deleteFiles()
|
||||
exit;
|
||||
}
|
||||
|
||||
public function downloadZip()
|
||||
{
|
||||
$this->_jsonStart();
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
public function zipStatus()
|
||||
{
|
||||
if (!$this->_requireAuth()) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(["error"=>"Unauthorized"]); return; }
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$token = isset($_GET['k']) ? preg_replace('/[^a-f0-9]/','',(string)$_GET['k']) : '';
|
||||
if ($token === '' || strlen($token) < 8) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error"=>"Bad token"]); return; }
|
||||
|
||||
$data = $this->_readJsonBody();
|
||||
if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) {
|
||||
$this->_jsonOut(["error" => "Invalid input."], 400); return;
|
||||
}
|
||||
$tokFile = rtrim((string)META_DIR, '/\\') . '/ziptmp/.tokens/' . $token . '.json';
|
||||
if (!is_file($tokFile)) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(["error"=>"Not found"]); return; }
|
||||
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
|
||||
if (($job['user'] ?? '') !== $username) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(["error"=>"Forbidden"]); return; }
|
||||
|
||||
$folder = $this->_normalizeFolder($data['folder']);
|
||||
$files = $data['files'];
|
||||
if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; }
|
||||
$ready = (($job['status'] ?? '') === 'done') && !empty($job['zipPath']) && is_file($job['zipPath']);
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$perms = $this->loadPerms($username);
|
||||
$out = [
|
||||
'status' => $job['status'] ?? 'unknown',
|
||||
'error' => $job['error'] ?? null,
|
||||
'ready' => $ready,
|
||||
// progress (if present)
|
||||
'pct' => $job['pct'] ?? null,
|
||||
'filesDone' => $job['filesDone'] ?? null,
|
||||
'filesTotal' => $job['filesTotal'] ?? null,
|
||||
'bytesDone' => $job['bytesDone'] ?? null,
|
||||
'bytesTotal' => $job['bytesTotal'] ?? null,
|
||||
'current' => $job['current'] ?? null,
|
||||
'phase' => $job['phase'] ?? null,
|
||||
// timing (always include for UI)
|
||||
'startedAt' => $job['startedAt'] ?? null,
|
||||
'finalizeAt' => $job['finalizeAt'] ?? null,
|
||||
];
|
||||
|
||||
// Optional zip gate by account flag
|
||||
if (!$this->isAdmin($perms) && !empty($perms['disableZip'])) {
|
||||
$this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403); return;
|
||||
}
|
||||
if ($ready) {
|
||||
$out['size'] = @filesize($job['zipPath']) ?: null;
|
||||
$out['downloadUrl'] = '/api/file/downloadZipFile.php?k=' . urlencode($token);
|
||||
}
|
||||
|
||||
$ignoreOwnership = $this->isAdmin($perms)
|
||||
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
echo json_encode($out);
|
||||
}
|
||||
|
||||
// Ancestor-owner counts as full view
|
||||
$fullView = $ignoreOwnership
|
||||
|| ACL::canRead($username, $perms, $folder)
|
||||
|| $this->ownsFolderOrAncestor($folder, $username, $perms);
|
||||
$ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
|
||||
public function downloadZipFile()
|
||||
{
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); echo "Unauthorized"; return; }
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$token = isset($_GET['k']) ? preg_replace('/[^a-f0-9]/','',(string)$_GET['k']) : '';
|
||||
if ($token === '' || strlen($token) < 8) { http_response_code(400); echo "Bad token"; return; }
|
||||
|
||||
if (!$fullView && !$ownOnly) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no view access to this folder."], 403); return;
|
||||
}
|
||||
$tokFile = rtrim((string)META_DIR, '/\\') . '/ziptmp/.tokens/' . $token . '.json';
|
||||
if (!is_file($tokFile)) { http_response_code(404); echo "Not found"; return; }
|
||||
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
|
||||
@unlink($tokFile); // one-shot token
|
||||
|
||||
// If own-only, ensure all files are owned by the user
|
||||
if ($ownOnly) {
|
||||
$meta = $this->loadFolderMetadata($folder);
|
||||
foreach ($files as $f) {
|
||||
$bn = basename((string)$f);
|
||||
if (!isset($meta[$bn]['uploader']) || strcasecmp((string)$meta[$bn]['uploader'], $username) !== 0) {
|
||||
$this->_jsonOut(["error" => "Forbidden: you are not the owner of '{$bn}'."], 403); return;
|
||||
}
|
||||
if (($job['user'] ?? '') !== $username) { http_response_code(403); echo "Forbidden"; return; }
|
||||
$zip = (string)($job['zipPath'] ?? '');
|
||||
$zipReal = realpath($zip);
|
||||
$root = realpath(rtrim((string)META_DIR, '/\\') . '/ziptmp');
|
||||
if (!$zipReal || !$root || strpos($zipReal, $root) !== 0 || !is_file($zipReal)) { http_response_code(404); echo "Not found"; return; }
|
||||
|
||||
@session_write_close();
|
||||
@set_time_limit(0);
|
||||
@ignore_user_abort(true);
|
||||
if (function_exists('apache_setenv')) @apache_setenv('no-gzip','1');
|
||||
@ini_set('zlib.output_compression','0');
|
||||
@ini_set('output_buffering','off');
|
||||
while (ob_get_level()>0) @ob_end_clean();
|
||||
|
||||
@clearstatcache(true, $zipReal);
|
||||
$name = isset($_GET['name']) ? preg_replace('/[^A-Za-z0-9._-]/','_', (string)$_GET['name']) : 'files.zip';
|
||||
if ($name === '' || str_ends_with($name,'.')) $name = 'files.zip';
|
||||
$size = (int)@filesize($zipReal);
|
||||
|
||||
header('X-Accel-Buffering: no');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Content-Type: application/zip');
|
||||
header('Content-Disposition: attachment; filename="'.$name.'"');
|
||||
if ($size>0) header('Content-Length: '.$size);
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
readfile($zipReal);
|
||||
@unlink($zipReal);
|
||||
}
|
||||
|
||||
public function downloadZip()
|
||||
{
|
||||
try {
|
||||
if (!$this->_checkCsrf()) { $this->_jsonOut(["error"=>"Bad CSRF"],400); return; }
|
||||
if (!$this->_requireAuth()) { $this->_jsonOut(["error"=>"Unauthorized"],401); return; }
|
||||
|
||||
$data = $this->_readJsonBody();
|
||||
if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) {
|
||||
$this->_jsonOut(["error" => "Invalid input."], 400); return;
|
||||
}
|
||||
|
||||
$folder = $this->_normalizeFolder($data['folder']);
|
||||
$files = $data['files'];
|
||||
if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; }
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$perms = $this->loadPerms($username);
|
||||
|
||||
// Optional zip gate by account flag
|
||||
if (!$this->isAdmin($perms) && !empty($perms['disableZip'])) {
|
||||
$this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403); return;
|
||||
}
|
||||
|
||||
$ignoreOwnership = $this->isAdmin($perms)
|
||||
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
|
||||
// Ancestor-owner counts as full view
|
||||
$fullView = $ignoreOwnership
|
||||
|| ACL::canRead($username, $perms, $folder)
|
||||
|| $this->ownsFolderOrAncestor($folder, $username, $perms);
|
||||
$ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
|
||||
|
||||
if (!$fullView && !$ownOnly) { $this->_jsonOut(["error" => "Forbidden: no view access to this folder."], 403); return; }
|
||||
|
||||
// If own-only, ensure all files are owned by the user
|
||||
if ($ownOnly) {
|
||||
$meta = $this->loadFolderMetadata($folder);
|
||||
foreach ($files as $f) {
|
||||
$bn = basename((string)$f);
|
||||
if (!isset($meta[$bn]['uploader']) || strcasecmp((string)$meta[$bn]['uploader'], $username) !== 0) {
|
||||
$this->_jsonOut(["error" => "Forbidden: you are not the owner of '{$bn}'."], 403); return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = FileModel::createZipArchive($folder, $files);
|
||||
if (isset($result['error'])) {
|
||||
$this->_jsonOut(["error" => $result['error']], 400); return;
|
||||
$root = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
|
||||
$tokDir = $root . DIRECTORY_SEPARATOR . '.tokens';
|
||||
$logDir = $root . DIRECTORY_SEPARATOR . '.logs';
|
||||
if (!is_dir($tokDir)) @mkdir($tokDir, 0700, true);
|
||||
if (!is_dir($logDir)) @mkdir($logDir, 0700, true);
|
||||
@chmod($tokDir, 0700);
|
||||
@chmod($logDir, 0700);
|
||||
if (!is_dir($tokDir) || !is_writable($tokDir)) {
|
||||
$this->_jsonOut(["error"=>"ZIP token dir not writable."],500); return;
|
||||
}
|
||||
|
||||
// Light janitor: purge old tokens/logs > 6h (best-effort)
|
||||
$now = time();
|
||||
foreach ((glob($tokDir . DIRECTORY_SEPARATOR . '*.json') ?: []) as $tf) {
|
||||
if (is_file($tf) && ($now - (int)@filemtime($tf)) > 21600) { @unlink($tf); }
|
||||
}
|
||||
foreach ((glob($logDir . DIRECTORY_SEPARATOR . 'WORKER-*.log') ?: []) as $lf) {
|
||||
if (is_file($lf) && ($now - (int)@filemtime($lf)) > 21600) { @unlink($lf); }
|
||||
}
|
||||
|
||||
// Per-user and global caps (simple anti-DoS)
|
||||
$perUserCap = 2; // tweak if desired
|
||||
$globalCap = 8; // tweak if desired
|
||||
|
||||
$tokens = glob($tokDir . DIRECTORY_SEPARATOR . '*.json') ?: [];
|
||||
$mine = 0; $all = 0;
|
||||
foreach ($tokens as $tf) {
|
||||
$job = json_decode((string)@file_get_contents($tf), true) ?: [];
|
||||
$st = $job['status'] ?? 'unknown';
|
||||
if ($st === 'queued' || $st === 'working' || $st === 'finalizing') {
|
||||
$all++;
|
||||
if (($job['user'] ?? '') === $username) $mine++;
|
||||
}
|
||||
}
|
||||
if ($mine >= $perUserCap) { $this->_jsonOut(["error"=>"You already have ZIP jobs running. Try again shortly."], 429); return; }
|
||||
if ($all >= $globalCap) { $this->_jsonOut(["error"=>"ZIP queue is busy. Try again shortly."], 429); return; }
|
||||
|
||||
$zipPath = $result['zipPath'] ?? null;
|
||||
if (!$zipPath || !file_exists($zipPath)) { $this->_jsonOut(["error"=>"ZIP archive not found."], 500); return; }
|
||||
// Create job token
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$tokFile = $tokDir . DIRECTORY_SEPARATOR . $token . '.json';
|
||||
$job = [
|
||||
'user' => $username,
|
||||
'folder' => $folder,
|
||||
'files' => array_values($files),
|
||||
'status' => 'queued',
|
||||
'ctime' => time(),
|
||||
'startedAt' => null,
|
||||
'finalizeAt' => null,
|
||||
'zipPath' => null,
|
||||
'error' => null
|
||||
];
|
||||
if (file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||
$this->_jsonOut(["error"=>"Failed to create zip job."],500); return;
|
||||
}
|
||||
|
||||
// switch to file streaming
|
||||
header_remove('Content-Type');
|
||||
header('Content-Type: application/zip');
|
||||
header('Content-Disposition: attachment; filename="files.zip"');
|
||||
header('Content-Length: ' . filesize($zipPath));
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
// Robust spawn (detect php CLI, log, record PID)
|
||||
$spawn = $this->spawnZipWorker($token, $tokFile, $logDir);
|
||||
if (!$spawn['ok']) {
|
||||
$job['status'] = 'error';
|
||||
$job['error'] = 'Spawn failed: '.$spawn['error'];
|
||||
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
$this->_jsonOut(["error"=>"Failed to enqueue ZIP: ".$spawn['error']], 500);
|
||||
return;
|
||||
}
|
||||
|
||||
readfile($zipPath);
|
||||
@unlink($zipPath);
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::downloadZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while preparing ZIP.'], 500);
|
||||
} finally { $this->_jsonEnd(); }
|
||||
$this->_jsonOut([
|
||||
'ok' => true,
|
||||
'token' => $token,
|
||||
'status' => 'queued',
|
||||
'statusUrl' => '/api/file/zipStatus.php?k=' . urlencode($token),
|
||||
'downloadUrl' => '/api/file/downloadZipFile.php?k=' . urlencode($token)
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::downloadZip enqueue error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal error while queuing ZIP.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function extractZip()
|
||||
{
|
||||
|
||||
@@ -30,6 +30,13 @@ class FolderController
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/** Stats for a folder (currently: empty/non-empty via folders/files counts). */
|
||||
public static function stats(string $folder, string $user, array $perms): array
|
||||
{
|
||||
// Normalize inside model; this is a thin action
|
||||
return FolderModel::countVisible($folder, $user, $perms);
|
||||
}
|
||||
|
||||
private static function requireCsrf(): void
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
@@ -16,6 +16,23 @@ private const OO_SUPPORTED_EXTS = [
|
||||
'ppt','pptx','odp',
|
||||
'pdf'
|
||||
];
|
||||
|
||||
/** Origin that the Document Server should use to reach FileRise fast (internal URL) */
|
||||
private function effectiveFileOriginForDocs(): string
|
||||
{
|
||||
$cfg = AdminModel::getConfig();
|
||||
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
|
||||
|
||||
// 1) explicit constant
|
||||
if (defined('ONLYOFFICE_FILE_ORIGIN_FOR_DOCS') && ONLYOFFICE_FILE_ORIGIN_FOR_DOCS !== '') {
|
||||
return (string)ONLYOFFICE_FILE_ORIGIN_FOR_DOCS;
|
||||
}
|
||||
// 2) admin.json setting
|
||||
if (!empty($oo['fileOriginForDocs'])) return (string)$oo['fileOriginForDocs'];
|
||||
|
||||
// 3) fallback: whatever the public sees (may hairpin, but still works)
|
||||
return $this->effectivePublicOrigin();
|
||||
}
|
||||
|
||||
// Never editable via OO (we’ll always set edit=false for these)
|
||||
private const OO_NEVER_EDIT = ['pdf'];
|
||||
@@ -127,117 +144,119 @@ private function ooLog(string $level, string $msg): void
|
||||
|
||||
/** GET /api/onlyoffice/status.php */
|
||||
public function status(): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$enabled = $this->effectiveEnabled();
|
||||
$docsOrig = $this->effectiveDocsOrigin();
|
||||
$secret = $this->effectiveSecret();
|
||||
$enabled = $this->effectiveEnabled();
|
||||
$docsOrig = $this->effectiveDocsOrigin();
|
||||
$secret = $this->effectiveSecret();
|
||||
|
||||
// Must have docs origin and secret to actually function
|
||||
$enabled = $enabled && ($docsOrig !== '') && ($secret !== '');
|
||||
// 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);
|
||||
}
|
||||
$exts = self::OO_SUPPORTED_EXTS;
|
||||
$exts = array_values(array_unique(array_merge($exts, self::OO_VIEW_ONLY_EXTRAS)));
|
||||
|
||||
echo json_encode([
|
||||
'enabled' => (bool)$enabled,
|
||||
'exts' => $exts,
|
||||
'docsOrigin' => $docsOrig, // <-- for preconnect/api.js
|
||||
'publicOrigin' => $this->effectivePublicOrigin() // <-- informational
|
||||
], 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');
|
||||
// --- config(): use the DocServer-facing origin for fileUrl & callbackUrl ---
|
||||
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);
|
||||
@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; }
|
||||
$enabled = $this->effectiveEnabled();
|
||||
$docsOrigin = rtrim($this->effectiveDocsOrigin(), '/');
|
||||
$secret = $this->effectiveSecret();
|
||||
|
||||
// Inputs
|
||||
$folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root'));
|
||||
$file = basename((string)($_GET['file'] ?? ''));
|
||||
if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; }
|
||||
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; }
|
||||
|
||||
// ACL
|
||||
if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; }
|
||||
$canEdit = \ACL::canEdit($user, $perms, $folder);
|
||||
$folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root'));
|
||||
$file = basename((string)($_GET['file'] ?? ''));
|
||||
if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; }
|
||||
|
||||
// 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; }
|
||||
if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; }
|
||||
$canEdit = \ACL::canEdit($user, $perms, $folder);
|
||||
|
||||
// Public origin
|
||||
$publicOrigin = $this->effectivePublicOrigin();
|
||||
$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; }
|
||||
|
||||
// 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);
|
||||
// IMPORTANT: use the internal/fast origin for DocServer fetch + callback
|
||||
$fileOriginForDocs = rtrim($this->effectiveFileOriginForDocs(), '/');
|
||||
|
||||
// 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;
|
||||
$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 = $fileOriginForDocs . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok);
|
||||
|
||||
// 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);
|
||||
$cbExp = time() + 10*60;
|
||||
$cbSig = hash_hmac('sha256', $folder.'|'.$file.'|'.$cbExp, $secret);
|
||||
$callbackUrl = $fileOriginForDocs . '/api/onlyoffice/callback.php'
|
||||
. '?folder=' . rawurlencode($folder)
|
||||
. '&file=' . rawurlencode($file)
|
||||
. '&exp=' . $cbExp
|
||||
. '&sig=' . $cbSig;
|
||||
|
||||
$docsApiJs = $docsOrigin . '/web-apps/apps/api/documents/api.js';
|
||||
$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);
|
||||
|
||||
$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',
|
||||
];
|
||||
$docsApiJs = $docsOrigin . '/web-apps/apps/api/documents/api.js';
|
||||
|
||||
// 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;
|
||||
$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',
|
||||
];
|
||||
|
||||
echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
// 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";
|
||||
|
||||
// expose to client for preconnect/script load
|
||||
$cfgOut['docs_api_js'] = $docsApiJs;
|
||||
$cfgOut['documentServerOrigin'] = $docsOrigin;
|
||||
|
||||
echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/** POST /api/onlyoffice/callback.php?folder=...&file=...&exp=...&sig=... */
|
||||
public function callback(): void
|
||||
@@ -343,41 +362,52 @@ private function ooLog(string $level, string $msg): void
|
||||
|
||||
/** GET /api/onlyoffice/signed-download.php?tok=... */
|
||||
public function signedDownload(): void
|
||||
{
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store');
|
||||
{
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$secret = $this->effectiveSecret();
|
||||
if ($secret === '') { http_response_code(403); return; }
|
||||
$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; }
|
||||
$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; }
|
||||
$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; }
|
||||
$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']);
|
||||
$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; }
|
||||
$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);
|
||||
// Common headers
|
||||
$mime = mime_content_type($abs) ?: 'application/octet-stream';
|
||||
$len = filesize($abs);
|
||||
header('Content-Type: '.$mime);
|
||||
header('Content-Length: '.$len);
|
||||
header('Content-Disposition: inline; filename="' . rawurlencode($file) . '"');
|
||||
header('Accept-Ranges: none'); // OO doesn’t require ranges; avoids partial edge-cases
|
||||
|
||||
// ---- Key change: for HEAD, do NOT read the file ----
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'HEAD') {
|
||||
// send headers only; no body
|
||||
return;
|
||||
}
|
||||
|
||||
// GET → stream the file
|
||||
readfile($abs);
|
||||
}
|
||||
}
|
||||
@@ -557,6 +557,13 @@ class FileModel {
|
||||
* @return array An associative array with either an "error" key or a "zipPath" key.
|
||||
*/
|
||||
public static function createZipArchive($folder, $files) {
|
||||
// Purge old temp zips > 6h (best-effort)
|
||||
$zipRoot = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
|
||||
$now = time();
|
||||
foreach ((glob($zipRoot . DIRECTORY_SEPARATOR . 'download-*.zip') ?: []) as $zp) {
|
||||
if (is_file($zp) && ($now - (int)@filemtime($zp)) > 21600) { @unlink($zp); }
|
||||
}
|
||||
|
||||
// Normalize and validate target folder
|
||||
$folder = trim((string)$folder) ?: 'root';
|
||||
$baseDir = realpath(UPLOAD_DIR);
|
||||
@@ -567,7 +574,6 @@ class FileModel {
|
||||
if (strtolower($folder) === 'root' || $folder === "") {
|
||||
$folderPathReal = $baseDir;
|
||||
} else {
|
||||
// Prevent traversal and validate each segment against folder regex
|
||||
if (strpos($folder, '..') !== false) {
|
||||
return ["error" => "Invalid folder name."];
|
||||
}
|
||||
@@ -592,6 +598,10 @@ class FileModel {
|
||||
continue;
|
||||
}
|
||||
$fullPath = $folderPathReal . DIRECTORY_SEPARATOR . $fileName;
|
||||
// Skip symlinks (avoid archiving outside targets via links)
|
||||
if (is_link($fullPath)) {
|
||||
continue;
|
||||
}
|
||||
if (is_file($fullPath)) {
|
||||
$filesToZip[] = $fullPath;
|
||||
}
|
||||
@@ -602,9 +612,7 @@ class FileModel {
|
||||
|
||||
// Workspace on the big disk: META_DIR/ziptmp
|
||||
$work = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
|
||||
if (!is_dir($work)) {
|
||||
@mkdir($work, 0775, true);
|
||||
}
|
||||
if (!is_dir($work)) { @mkdir($work, 0775, true); }
|
||||
if (!is_dir($work) || !is_writable($work)) {
|
||||
return ["error" => "ZIP temp dir not writable: " . $work];
|
||||
}
|
||||
@@ -626,7 +634,7 @@ class FileModel {
|
||||
|
||||
@set_time_limit(0);
|
||||
|
||||
// Create the ZIP path inside META_DIR/ziptmp
|
||||
// Create the ZIP path inside META_DIR/ziptmp (libzip temp stays on same FS)
|
||||
$zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
|
||||
$zipPath = $work . DIRECTORY_SEPARATOR . $zipName;
|
||||
|
||||
@@ -636,7 +644,7 @@ class FileModel {
|
||||
}
|
||||
|
||||
foreach ($filesToZip as $filePath) {
|
||||
// Add using basename at the root of the zip (matches your current behavior)
|
||||
// Add using basename at the root of the zip (matches current behavior)
|
||||
$zip->addFile($filePath, basename($filePath));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,45 @@ class FolderModel
|
||||
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
|
||||
* ============================================================ */
|
||||
|
||||
public static function countVisible(string $folder, string $user, array $perms): array
|
||||
{
|
||||
// Normalize
|
||||
$folder = ACL::normalizeFolder($folder);
|
||||
|
||||
// ACL gate: if you can’t read, report empty (no leaks)
|
||||
if (!$user || !ACL::canRead($user, $perms, $folder)) {
|
||||
return ['folders' => 0, 'files' => 0];
|
||||
}
|
||||
|
||||
// Resolve paths under UPLOAD_DIR
|
||||
$root = rtrim((string)UPLOAD_DIR, '/\\');
|
||||
$path = ($folder === 'root') ? $root : ($root . '/' . $folder);
|
||||
|
||||
$realRoot = @realpath($root);
|
||||
$realPath = @realpath($path);
|
||||
if ($realRoot === false || $realPath === false || strpos($realPath, $realRoot) !== 0) {
|
||||
return ['folders' => 0, 'files' => 0];
|
||||
}
|
||||
|
||||
// Count quickly, skipping UI-internal dirs
|
||||
$folders = 0; $files = 0;
|
||||
try {
|
||||
foreach (new DirectoryIterator($realPath) as $f) {
|
||||
if ($f->isDot()) continue;
|
||||
$name = $f->getFilename();
|
||||
if ($name === 'trash' || $name === 'profile_pics') continue;
|
||||
|
||||
if ($f->isDir()) $folders++; else $files++;
|
||||
if ($folders > 0 || $files > 0) break; // short-circuit: we only care if empty vs not
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Stay quiet + safe
|
||||
$folders = 0; $files = 0;
|
||||
}
|
||||
|
||||
return ['folders' => $folders, 'files' => $files];
|
||||
}
|
||||
|
||||
/** Load the folder → owner map. */
|
||||
public static function getFolderOwners(): array
|
||||
{
|
||||
|
||||