Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a031fc99c2 | ||
|
|
db73cf2876 | ||
|
|
062f34dd3d | ||
|
|
63b24ba698 | ||
|
|
567d2f62e8 | ||
|
|
9be53ba033 | ||
|
|
de925e6fc2 | ||
|
|
bd7ff4d9cd | ||
|
|
6727cc66ac | ||
|
|
f3269877c7 | ||
|
|
5ffe9b3ffc | ||
|
|
abd3dad5a5 | ||
|
|
4c849b1dc3 | ||
|
|
7cc314179f | ||
|
|
9ddb633cca | ||
|
|
448e246689 | ||
|
|
dc7797e50d | ||
|
|
913d370ef2 | ||
|
|
488b5cb532 | ||
|
|
15b5aa6d8d | ||
|
|
8f03cc7456 | ||
|
|
c9a99506d7 | ||
|
|
04ec0a0830 | ||
|
|
429cd0314a | ||
|
|
ba29cc4822 | ||
|
|
e2cd304158 | ||
|
|
ca8788a694 | ||
|
|
dc45fed886 | ||
|
|
a9fe342175 | ||
|
|
7669f5a10b | ||
|
|
34a4e06a23 | ||
|
|
d00faf5fe7 | ||
|
|
ad8cbc601a | ||
|
|
40e000b5bc | ||
|
|
eee25a4dc6 | ||
|
|
d66f4d93cb | ||
|
|
f4f7f8ef38 | ||
|
|
0ccba45c40 | ||
|
|
620c916eb3 | ||
|
|
f809cc09d2 | ||
|
|
6758b5f73d | ||
|
|
30a0aaf05e | ||
|
|
c843f00738 | ||
|
|
4bb9d81370 | ||
|
|
29e0497730 | ||
|
|
dd3a7a5145 | ||
|
|
d00db803c3 | ||
|
|
77a94ecd85 | ||
|
|
699873848e | ||
|
|
9cb12c11a6 | ||
|
|
c08876380b | ||
|
|
5b824888cb | ||
|
|
b7d7f7c3ce |
118
.github/workflows/release-on-version.yml
vendored
@@ -6,9 +6,14 @@ on:
|
||||
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/sha) to build from (default: master)"
|
||||
required: false
|
||||
version:
|
||||
description: "Explicit version tag to release (e.g., v1.8.12). If empty, parse from public/js/version.js."
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -16,32 +21,74 @@ permissions:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Only run on:
|
||||
# - push (master + version.js path filter already enforces that)
|
||||
# - manual dispatch
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|
||||
# Duplicate safety; also step "Skip if tag exists" will no-op if already released.
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}-${{ github.sha }}
|
||||
group: release-${{ github.event_name }}-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Resolve source ref
|
||||
id: pickref
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
if [[ -n "${{ github.event.inputs.ref }}" ]]; then
|
||||
REF_IN="${{ github.event.inputs.ref }}"
|
||||
else
|
||||
REF_IN="master"
|
||||
fi
|
||||
# Resolve to a commit sha (allow branches or shas)
|
||||
if git ls-remote --exit-code --heads https://github.com/${{ github.repository }}.git "$REF_IN" >/dev/null 2>&1; then
|
||||
REF="$REF_IN"
|
||||
else
|
||||
# Accept SHAs too; we’ll let checkout validate
|
||||
REF="$REF_IN"
|
||||
fi
|
||||
else
|
||||
REF="${{ github.sha }}"
|
||||
fi
|
||||
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
echo "Using ref=$REF"
|
||||
|
||||
- name: Checkout chosen ref (full history + tags, no persisted token)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.pickref.outputs.ref }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Ensure tags available
|
||||
run: |
|
||||
git fetch --tags --force --prune --quiet
|
||||
|
||||
- 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/")
|
||||
if [[ -z "$VER" ]]; then
|
||||
echo "Could not parse APP_VERSION from version.js" >&2
|
||||
exit 1
|
||||
if [[ -n "${{ github.event.inputs.version || '' }}" ]]; then
|
||||
VER="${{ github.event.inputs.version }}"
|
||||
else
|
||||
# Parse APP_VERSION from public/js/version.js (expects vX.Y.Z)
|
||||
if [[ ! -f public/js/version.js ]]; then
|
||||
echo "public/js/version.js not found; cannot auto-detect version." >&2
|
||||
exit 1
|
||||
fi
|
||||
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 public/js/version.js" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Parsed version: $VER"
|
||||
echo "Detected version: $VER"
|
||||
|
||||
- name: Skip if tag already exists
|
||||
id: tagcheck
|
||||
@@ -55,8 +102,7 @@ jobs:
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Ensure the stamper is executable and has LF endings (helps if edited on Windows)
|
||||
- name: Prep stamper script
|
||||
- name: Prepare stamp script
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -64,26 +110,21 @@ jobs:
|
||||
sed -i 's/\r$//' scripts/stamp-assets.sh || true
|
||||
chmod +x scripts/stamp-assets.sh
|
||||
|
||||
- name: Build zip artifact (stamped)
|
||||
- name: Build stamped staging tree
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}" # e.g. v1.6.12
|
||||
ZIP="FileRise-${VER}.zip"
|
||||
|
||||
# Clean staging copy (exclude dotfiles you don’t want)
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
rm -rf staging
|
||||
rsync -a \
|
||||
--exclude '.git' --exclude '.github' \
|
||||
--exclude 'resources' \
|
||||
--exclude '.dockerignore' --exclude '.gitattributes' --exclude '.gitignore' \
|
||||
./ staging/
|
||||
|
||||
# Stamp IN THE STAGING COPY (invoke via bash to avoid exec-bit issues)
|
||||
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/staging"
|
||||
|
||||
- name: Verify placeholders are gone (staging)
|
||||
- name: Verify placeholders removed
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -91,28 +132,20 @@ jobs:
|
||||
ROOT="$(pwd)/staging"
|
||||
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
||||
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
|
||||
echo "---- DEBUG (show 10 hits with context) ----"
|
||||
grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
||||
--include='*.html' --include='*.php' --include='*.css' --include='*.js' \
|
||||
| head -n 10 | while IFS=: read -r file line _; do
|
||||
echo ">>> $file:$line"
|
||||
nl -ba "$file" | sed -n "$((line-3)),$((line+3))p" || true
|
||||
echo "----------------------------------------"
|
||||
done
|
||||
echo "Unreplaced placeholders found in staging." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: No unreplaced placeholders in staging."
|
||||
echo "OK: No unreplaced placeholders."
|
||||
|
||||
- name: Zip stamped staging
|
||||
- name: Zip artifact
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
ZIP="FileRise-${VER}.zip"
|
||||
(cd staging && zip -r "../$ZIP" . >/dev/null)
|
||||
(cd staging && zip -r "../FileRise-${VER}.zip" . >/dev/null)
|
||||
|
||||
- name: Compute SHA-256 checksum
|
||||
- name: Compute SHA-256
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
id: sum
|
||||
shell: bash
|
||||
@@ -157,9 +190,9 @@ jobs:
|
||||
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
|
||||
fi
|
||||
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
|
||||
echo "Previous tag or baseline: $PREV"
|
||||
echo "Previous tag/baseline: $PREV"
|
||||
|
||||
- name: Build release body (snippet + full changelog + checksum)
|
||||
- name: Build release body
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -170,7 +203,6 @@ jobs:
|
||||
COMPARE_URL="https://github.com/${REPO}/compare/${PREV}...${VER}"
|
||||
ZIP="FileRise-${VER}.zip"
|
||||
SHA="${{ steps.sum.outputs.sha }}"
|
||||
|
||||
{
|
||||
echo
|
||||
if [[ -s CHANGELOG_SNIPPET.md ]]; then
|
||||
@@ -186,8 +218,6 @@ jobs:
|
||||
echo "${SHA} ${ZIP}"
|
||||
echo '```'
|
||||
} > RELEASE_BODY.md
|
||||
|
||||
echo "Release body:"
|
||||
sed -n '1,200p' RELEASE_BODY.md
|
||||
|
||||
- name: Create GitHub Release
|
||||
@@ -195,7 +225,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.ver.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
target_commitish: ${{ steps.pickref.outputs.ref }}
|
||||
name: ${{ steps.ver.outputs.version }}
|
||||
body_path: RELEASE_BODY.md
|
||||
generate_release_notes: false
|
||||
|
||||
31
.github/workflows/sync-changelog.yml
vendored
@@ -5,18 +5,25 @@ on:
|
||||
push:
|
||||
paths:
|
||||
- "CHANGELOG.md"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: bump-and-sync-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
bump_and_sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout FileRise
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Extract version from commit message
|
||||
id: ver
|
||||
@@ -32,6 +39,23 @@ jobs:
|
||||
echo "No release(vX.Y.Z) tag in commit message; skipping bump."
|
||||
fi
|
||||
|
||||
# Ensure we're on the branch and up to date BEFORE modifying files
|
||||
- name: Ensure clean branch (no local mods), update from remote
|
||||
if: steps.ver.outputs.version != ''
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Be on a named branch that tracks the remote
|
||||
git checkout -B "${{ github.ref_name }}" --track "origin/${{ github.ref_name }}" || git checkout -B "${{ github.ref_name }}"
|
||||
# Make sure the worktree is clean
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "::error::Working tree not clean before update. Aborting."
|
||||
git status --porcelain
|
||||
exit 1
|
||||
fi
|
||||
# Update branch
|
||||
git pull --rebase origin "${{ github.ref_name }}"
|
||||
|
||||
- name: Update public/js/version.js (source of truth)
|
||||
if: steps.ver.outputs.version != ''
|
||||
shell: bash
|
||||
@@ -42,8 +66,6 @@ jobs:
|
||||
window.APP_VERSION = '${{ steps.ver.outputs.version }}';
|
||||
EOF
|
||||
|
||||
# ✂️ REMOVED: repo stamping of HTML/CSS/JS
|
||||
|
||||
- name: Commit version.js only
|
||||
if: steps.ver.outputs.version != ''
|
||||
shell: bash
|
||||
@@ -56,7 +78,7 @@ jobs:
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "chore(release): set APP_VERSION to ${{ steps.ver.outputs.version }} [skip ci]"
|
||||
git push
|
||||
git push origin "${{ github.ref_name }}"
|
||||
fi
|
||||
|
||||
- name: Checkout filerise-docker
|
||||
@@ -66,6 +88,7 @@ jobs:
|
||||
repository: error311/filerise-docker
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
path: docker-repo
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Copy CHANGELOG.md and write VERSION
|
||||
if: steps.ver.outputs.version != ''
|
||||
|
||||
459
CHANGELOG.md
@@ -1,5 +1,464 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 11/10/2025 (v1.9.2)
|
||||
|
||||
release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback)
|
||||
|
||||
- New “Upload file(s)” action in Create menu:
|
||||
- Adds `<li id="uploadOption">` to the dropdown.
|
||||
- Opens a reusable Upload modal that *moves* the existing #uploadCard into the modal (no cloning = no lost listeners).
|
||||
- ESC / backdrop / “×” close support; focus jumps to “Choose Files” for fast keyboard flow.
|
||||
|
||||
- Drag & Drop from file list → Upload:
|
||||
- Drag-over on #fileListContainer shows drop-hover and auto-opens the Upload modal after a short hover.
|
||||
- On drop, waits until the modal’s #uploadDropArea exists, then relays the drop to it.
|
||||
- Uses a resilient relay: attempts to attach DataTransfer to a synthetic event; falls back to a stash.
|
||||
|
||||
- Synthetic drop fallback:
|
||||
- Introduces window.__pendingDropData (cleared after use).
|
||||
- upload.js now reads e.dataTransfer || window.__pendingDropData to accept relayed drops across browsers.
|
||||
|
||||
- Implementation details:
|
||||
- fileActions.js: adds openUploadModal()/closeUploadModal() with a hidden sentinel to return #uploadCard to its original place on close.
|
||||
- appCore.js: imports openUploadModal, adds waitFor() helper, and wires dragover/leave/drop logic for the relay.
|
||||
- index.html: adds Upload option to the Create menu and the #uploadModal scaffold.
|
||||
|
||||
- UX/Safety:
|
||||
- Defensive checks if modal/card isn’t present.
|
||||
- No backend/API changes; CSRF/auth unchanged.
|
||||
|
||||
Files touched: public/js/upload.js, public/js/fileActions.js, public/js/appCore.js, public/index.html
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/9/2025 (v1.9.1)
|
||||
|
||||
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
|
||||
|
||||
### Highlights v1.9.1
|
||||
|
||||
- 🎨 Per-folder colors with live SVG preview and consistent styling in light/dark modes.
|
||||
- 📄 Folder icons auto-refresh when contents change (no full page reload).
|
||||
- 🧭 Drag-and-drop breadcrumb fallback for folder→folder moves.
|
||||
- 🛠️ Safer upgrade helper script to rsync app files without touching data.
|
||||
|
||||
- feat(colors): add per-folder color customization
|
||||
- New endpoints: GET /api/folder/getFolderColors.php and POST /api/folder/saveFolderColor.php
|
||||
- AuthZ: reuse canRename for “customize folder”, validate hex, and write atomically to metadata/folder_colors.json.
|
||||
- Read endpoint filters map by ACL::canRead before returning to the user.
|
||||
- Frontend: load/apply colors to tree rows; persist on move/rename; API helpers saveFolderColor/getFolderColors.
|
||||
|
||||
- feat(ui): color-picker modal with live SVG folder preview
|
||||
- Shows preview that updates as you pick; supports Save/Reset; protects against accidental toggle clicks.
|
||||
|
||||
- feat(controls): “Color folder” button in Folder Management card
|
||||
- New `.btn-color-folder` with accent palette (#008CB4), hover/active/focus states, dark-mode tuning; event wiring gated by caps.
|
||||
|
||||
- i18n: add strings for color UI (color_folder, choose_color, reset_default, save_color, folder_color_saved, folder_color_cleared).
|
||||
|
||||
- ux(tree): make expansion state more predictable across refreshes
|
||||
- `expandTreePath(path, {force,persist,includeLeaf})` with persistence; keep ancestors expanded; add click-suppression guard.
|
||||
|
||||
- ux(layout): center the folder-actions toolbar; remove left padding hacks; normalize icon sizing.
|
||||
|
||||
- chore(ops): add scripts/manual-sync.sh (safe rsync update path, preserves data dirs and public/.htaccess).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
- Zip creation
|
||||
- Write archives to META_DIR/ziptmp (on large/writable disk) instead of system tmp.
|
||||
- Auto-create ziptmp (0775) and verify writability.
|
||||
- Free-space sanity check (~files total +5% +20MB); clearer error on low space.
|
||||
- Normalize/validate folder segments; include only regular files.
|
||||
- set_time_limit(0); use CREATE|OVERWRITE; improved error handling.
|
||||
|
||||
- Zip extraction
|
||||
- New: stamp metadata for files in nested subfolders (per-folder metadata.json).
|
||||
- Skip hidden “dot” paths (files/dirs with any segment starting with “.”) by default
|
||||
via SKIP_DOTFILES_ON_EXTRACT=true; only extract allow-listed entries.
|
||||
- Hardenings: zip-slip guard, reject symlinks (external_attributes), zip-bomb limits
|
||||
(MAX_UNZIP_BYTES default 200GiB, MAX_UNZIP_FILES default 20k).
|
||||
- Persist metadata for all touched folders; keep extractedFiles list for top-level names.
|
||||
|
||||
Ops note: ensure /var/www/metadata/ziptmp exists & is writable (or mount META_DIR to a large volume).
|
||||
|
||||
Closes #60.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/4/2025 (v1.8.5)
|
||||
|
||||
release(v1.8.5): ci: reduce pre-run delay to 2-min and add missing `needs: delay`, final test
|
||||
|
||||
- No change release just testing
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/4/2025 (v1.8.4)
|
||||
|
||||
release(v1.8.4): ci: add 3-min pre-run delay to avoid workflow_run races
|
||||
|
||||
- No change release just testing
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/4/2025 (v1.8.3)
|
||||
|
||||
release(v1.8.3): feat(mobile+ci): harden Capacitor switcher & make release-on-version robust
|
||||
|
||||
- switcher.js: allow running inside Capacitor; remove innerHTML usage; build nodes safely; normalize/strip creds from URLs; add withParam() for ?frapp=1; drop inline handlers; clamp rename length; minor UX polish.
|
||||
- CI: cancel superseded runs per ref; checkout triggering commit (workflow_run head_sha); improve APP_VERSION parsing; point tag to checked-out commit; add recent-tag debug.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/4/2025 (v1.8.2)
|
||||
|
||||
release(v1.8.2): media progress tracking + watched badges; PWA scaffolding; mobile switcher (closes #37)
|
||||
|
||||
- **Highlights**
|
||||
- Video: auto-save playback progress and mark “Watched”, with resume-on-open and inline status chips on list/gallery.
|
||||
- Mobile: introduced FileRise Mobile (Capacitor) companion repo + in-app server switcher and PWA bits.
|
||||
|
||||
- **Details**
|
||||
- API (new):
|
||||
- POST /api/media/updateProgress.php — persist per-user progress (seconds/duration/completed).
|
||||
- GET /api/media/getProgress.php — fetch per-file progress.
|
||||
- GET /api/media/getViewedMap.php — folder map for badges.
|
||||
|
||||
- **Frontend (media):**
|
||||
- Video previews now resume from last position, periodically save progress, and mark completed on end, with toasts.
|
||||
- Added status badges (“Watched” / %-complete) in table & gallery; CSS polish for badges.
|
||||
- Badges render during list/gallery refresh; safer filename wrapping for badge injection.
|
||||
|
||||
- **Mobile & PWA:**
|
||||
- New in-app server switcher (Capacitor-aware) loaded only in app/standalone contexts.
|
||||
- Service Worker + manifest added (root scope via /public/sw.js; worker body in /js/pwa/sw.js; manifest icons).
|
||||
- main.js conditionally imports the mobile switcher and registers the SW on web origins only.
|
||||
|
||||
- **Notes**
|
||||
- Companion repo: **filerise-mobile** (Capacitor app shell) created for iOS/Android distribution.
|
||||
- No breaking changes expected; endpoints are additive.
|
||||
|
||||
Closes #37.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/3/2025 (V1.8.1)
|
||||
|
||||
release(v1.8.1): fix(security,onlyoffice): sanitize DS origin; safe api.js/iframe probes; better UX placeholder
|
||||
|
||||
- Add ONLYOFFICE URL sanitizers:
|
||||
- getTrustedDocsOrigin(): enforce http/https, strip creds, normalize to origin
|
||||
- buildOnlyOfficeApiUrl(): construct fixed /web-apps/.../api.js via URL()
|
||||
- Probe hardening (addresses CodeQL js/xss-through-dom):
|
||||
- ooProbeScript/ooProbeFrame now use sanitized origins and fixed paths
|
||||
- optional CSP nonce support for injected script
|
||||
- optional iframe sandbox; robust cleanup/timeout handling
|
||||
- CSP helper now renders lines based on validated origin (fallback to raw for visibility)
|
||||
- Admin UI UX: placeholder switched to HTTPS example (`https://docs.example.com`)
|
||||
- Comments added to justify safety to static analyzers
|
||||
|
||||
Files: public/js/adminPanel.js
|
||||
|
||||
Refs: #37
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/3/2025 (v1.8.0)
|
||||
|
||||
release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers
|
||||
|
||||
Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately.
|
||||
|
||||
Adds secure, ACL-aware ONLYOFFICE support throughout FileRise:
|
||||
|
||||
- **Backend / API**
|
||||
- New OnlyOfficeController with supported extensions (doc/xls/ppt/pdf etc.), status/config endpoints, and signed download flow.
|
||||
- New endpoints:
|
||||
- GET /api/onlyoffice/status.php — reports availability + supported exts.
|
||||
- GET /api/onlyoffice/config.php — returns DocEditor config (signed URLs, callback).
|
||||
- GET /api/onlyoffice/signed-download.php — serves signed blobs to DS.
|
||||
- Effective config/overrides: env/constant wins; supports docsOrigin, publicOrigin, and jwtSecret; status gated on presence of origin+secret.
|
||||
- Public origin resolution (BASE_URL/proxy aware) for absolute URLs.
|
||||
|
||||
- **Admin config / UI**
|
||||
- AdminPanel gets a new “ONLYOFFICE” section with Enable toggle, Document Server Origin, masked JWT Secret, and “Replace” control.
|
||||
- Built-in connection tester (status, secret presence, callback ping, api.js load, iframe embed) + CSP helper (Apache & Nginx snippets)
|
||||
|
||||
- **Frontend integration**
|
||||
- fileEditor detects OO capability via /api/onlyoffice/status and routes supported types to the DocEditor; loads DocsAPI dynamically.
|
||||
- editFile() short-circuits to openOnlyOffice when applicable; includes live dark/light theme sync where supported.
|
||||
- fileListView pulls status once on load to drive UI decisions (e.g., editing affordances).
|
||||
|
||||
- **AdminModel / config**
|
||||
- Adds onlyoffice {enabled, docsOrigin, publicOrigin} defaults and update path, with jwtSecret persisted (kept unless explicitly replaced).
|
||||
- Optional constants in config.php to override and debug.
|
||||
|
||||
- **Security & UX notes**
|
||||
- Editor access remains ACL-checked (read/edit) and uses absolute, signed URLs surfaced via controller.
|
||||
- Admin UI never echoes secrets; “Replace” toggles explicit updates only.
|
||||
- CSP helper makes it straightforward to permit api.js + iframe + XHR to your DS.
|
||||
|
||||
- **Docs/Styling**
|
||||
- Minor CSS touch-ups around hover states and modal layout.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/2/2025 (v1.7.5)
|
||||
|
||||
release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50)
|
||||
release(v1.7.5): retrigger CI bump (no code changes)
|
||||
release(v1.7.5): retrigger CI bump ensure up to date
|
||||
|
||||
### Security/headers
|
||||
|
||||
- Tighten CSP: pin the inline pre-theme snippet with a script-src SHA-256 and keep everything else on 'self'.
|
||||
- Improve cache policy for versioned assets: force 1y + immutable and add s-maxage for CDNs; also avoid HSTS redirects on local/dev hosts.
|
||||
|
||||
### Previews & editor
|
||||
|
||||
- Remove hardcoded `/uploads/` paths; always build preview URLs via the API (respects UPLOAD_DIR/ACL).
|
||||
- Use the API URL for gallery prev/next and file-menu “Preview” to fix 404s on custom storage roots.
|
||||
- Editor now probes size safely (HEAD → Range 0-0 fallback) before fetching, then fetches with credentials.
|
||||
|
||||
### Login, theming & UX polish
|
||||
|
||||
- Pre-theme inline boot sets `dark-mode` + background early; swap to `[hidden]`/`unhide()` instead of inline `display:none`.
|
||||
- Add full-screen loading overlay with quick fade and proper color-scheme; prevent white/black flash on theme flips.
|
||||
- Refactor app/login reveal flow in `main.js` (`revealAppAndHideOverlay`, `authed` path, setup wizard).
|
||||
|
||||
### HTML/CSS & perf
|
||||
|
||||
- Make Bootstrap/Styles/Roboto critical (plain `<link rel="stylesheet">`); keep fonts as true preloads; modulepreload app entry.
|
||||
- Export a `__CSS_PROMISE__` from `defer-css.js` for sites that still promote preloads.
|
||||
- Header logo marked `fetchpriority="high"` for faster first paint.
|
||||
- Normalize dark-mode selectors to `.dark-mode` scope (admin panel, etc.).
|
||||
|
||||
### Manual Deploy script
|
||||
|
||||
- Add `scripts/filerise-deploy.sh`: idempotent rsync-based deploy with writable dirs preserved, optional Composer install, and PHP-FPM/Apache reloads.
|
||||
|
||||
### Notes
|
||||
|
||||
- If you change the inline pre-theme snippet, update the CSP hash accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/31/2025 (v1.7.4)
|
||||
|
||||
release(v1.7.4): login hint replace toast + fix unauth boot
|
||||
|
||||
76
README.md
@@ -10,7 +10,7 @@
|
||||
[](https://github.com/sponsors/error311)
|
||||
[](https://ko-fi.com/error311)
|
||||
|
||||
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting)
|
||||
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [ONLYOFFICE](#quick-start-onlyoffice-optional) • [FAQ](#faq--troubleshooting)
|
||||
|
||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
||||
Upload, organize, and share files or folders through a sleek, responsive web interface.
|
||||
@@ -21,14 +21,13 @@ Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage
|
||||
|
||||
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
|
||||
|
||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||
Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
|
||||
|
||||
**10/25/2025 Video demo:**
|
||||
|
||||
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
|
||||
|
||||
**Dark mode:**
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -74,6 +73,8 @@ With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2
|
||||
|
||||
- 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers.
|
||||
|
||||
- 🧩 **Office Docs (ONLYOFFICE, optional):** View/edit DOCX, XLSX, PPTX (and ODT/ODS/ODP, PDF view) using your self-hosted ONLYOFFICE Document Server. Enforced by the same ACLs as the web UI & WebDAV.
|
||||
|
||||
- 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents.
|
||||
|
||||
- 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak).
|
||||
@@ -323,27 +324,53 @@ https://your-host/webdav.php/
|
||||
- Check **Connect using different credentials**, then enter your FileRise username/password.
|
||||
- Click **Finish**.
|
||||
|
||||
> **Important:**
|
||||
> Windows requires HTTPS (SSL) for WebDAV connections by default.
|
||||
> If your server uses plain HTTP, you must adjust a registry setting:
|
||||
>
|
||||
> 1. Open **Registry Editor** (`regedit.exe`).
|
||||
> 2. Navigate to:
|
||||
>
|
||||
> ```text
|
||||
> HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters
|
||||
> ```
|
||||
>
|
||||
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
|
||||
> 4. Set its value to `2`.
|
||||
> 5. Restart the **WebClient** service or reboot.
|
||||
|
||||
📖 See the full [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV) for SSL setup, HTTP workaround, and troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## Quick start: ONLYOFFICE (optional)
|
||||
|
||||
FileRise can open & edit office docs using your **self-hosted ONLYOFFICE Document Server**.
|
||||
|
||||
**What you need**
|
||||
|
||||
- A reachable ONLYOFFICE Document Server (Community/Enterprise).
|
||||
- A shared **JWT secret** used by FileRise and your Document Server.
|
||||
|
||||
**Setup (2–3 minutes)**
|
||||
|
||||
1. In FileRise go to **Admin → ONLYOFFICE** and:
|
||||
- ✅ Enable ONLYOFFICE
|
||||
- 🔗 Set **Document Server Origin** (e.g., `https://docs.example.com`)
|
||||
- 🔑 Enter **JWT Secret** (click “Replace” to set)
|
||||
2. (Recommended) Click **Run tests** in the ONLYOFFICE card:
|
||||
- Checks FileRise status, callback reachability, `api.js` load, and iframe embed.
|
||||
3. Update your **Content-Security-Policy** to allow the DS origin.
|
||||
The Admin panel shows a ready-to-copy line for Apache & Nginx. Example:
|
||||
|
||||
**Apache**
|
||||
|
||||
```apache
|
||||
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com"
|
||||
```
|
||||
|
||||
**Nginx**
|
||||
|
||||
```nginx
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com" always;
|
||||
```
|
||||
|
||||
**Notes**
|
||||
- If your site is https://, your Document Server must also be https:// (or the browser will block it as mixed content).
|
||||
- Editor access respects FileRise ACLs (view/edit/share) exactly like the rest of the app.
|
||||
|
||||
---
|
||||
|
||||
## FAQ / Troubleshooting
|
||||
|
||||
- **ONLYOFFICE editor won’t load / blank frame:** Verify CSP allows your DS origin (`script-src`, `frame-src`, `connect-src`) and that the DS is reachable over HTTPS if your site is HTTPS.
|
||||
- **“Disabled — check JWT Secret / Origin” in tests:** In **Admin → ONLYOFFICE**, set the Document Server Origin and click “Replace” to save a JWT secret. Then re-run tests.
|
||||
|
||||
- **“Upload failed” or large files not uploading:** Ensure `TOTAL_UPLOAD_SIZE` in config and PHP’s `post_max_size` / `upload_max_filesize` are set high enough. For extremely large files, you might need to increase `max_execution_time` or rely on resumable uploads in smaller chunks.
|
||||
|
||||
- **How to enable HTTPS?** FileRise doesn’t terminate TLS itself. Run it behind a reverse proxy (Nginx, Caddy, Apache with SSL) or use a companion like nginx-proxy or Caddy in Docker. Set `SECURE="true"` in Docker so FileRise generates HTTPS links.
|
||||
@@ -360,6 +387,8 @@ For more Q&A or to ask for help, open a Discussion or Issue.
|
||||
|
||||
## Security posture
|
||||
|
||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||
|
||||
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening).
|
||||
Advisories: [GHSA-6p87-q9rh-95wh](https://github.com/error311/FileRise/security/advisories/GHSA-6p87-q9rh-95wh) (≤ 1.3.15), [GHSA-jm96-2w52-5qjj](https://github.com/error311/FileRise/security/advisories/GHSA-jm96-2w52-5qjj) (v1.4.0). Fixed in **v1.5.0**. Thanks to [@kiwi865](https://github.com/kiwi865) for reporting.
|
||||
If you’re running ≤1.4.x, please upgrade.
|
||||
@@ -399,6 +428,13 @@ Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
|
||||
|
||||
## Dependencies
|
||||
|
||||
### ONLYOFFICE integration
|
||||
|
||||
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**.
|
||||
- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use.
|
||||
– Project page & license: <https://github.com/ONLYOFFICE/DocumentServer> (AGPL-3.0)
|
||||
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
|
||||
|
||||
### PHP Libraries
|
||||
|
||||
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
|
||||
@@ -420,7 +456,7 @@ Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
||||
- [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -25,6 +25,17 @@ if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
|
||||
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
|
||||
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
|
||||
define('ACL_INHERIT_ON_CREATE', true);
|
||||
// ONLYOFFICE integration overrides (uncomment and set as needed)
|
||||
/*
|
||||
define('ONLYOFFICE_ENABLED', false);
|
||||
define('ONLYOFFICE_JWT_SECRET', 'test123456');
|
||||
define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server
|
||||
define('ONLYOFFICE_DEBUG', true);
|
||||
*/
|
||||
|
||||
if (!defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD')) {
|
||||
define('OIDC_TOKEN_ENDPOINT_AUTH_METHOD', 'client_secret_basic'); // default
|
||||
}
|
||||
|
||||
// Encryption helpers
|
||||
function encryptData($data, $encryptionKey)
|
||||
|
||||
@@ -1,35 +1,63 @@
|
||||
# --------------------------------
|
||||
# Base: safe in most environments
|
||||
# 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>
|
||||
<FilesMatch "^\.">
|
||||
# Block direct access to dotfiles like .env, .gitignore, etc.
|
||||
<FilesMatch "^\..*">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
# ---------------- Rewrites ----------------
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# --- HTTPS redirect ---
|
||||
# Use ONE of these blocks.
|
||||
# 0) Let ACME http-01 pass BEFORE any other rule (needed for auto-renew)
|
||||
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
|
||||
RewriteRule - - [L]
|
||||
|
||||
# A) Direct TLS on this server (enable this if Apache terminates HTTPS here)
|
||||
#RewriteCond %{HTTPS} off
|
||||
# 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
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# B) Behind a reverse proxy/CDN that sets X-Forwarded-Proto
|
||||
# B) Behind reverse proxy that sets X-Forwarded-Proto
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} =http [OR]
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} ^$
|
||||
#RewriteCond %{HTTPS} !=on
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# Don't interfere with ACME/http-01 if you do your own certs
|
||||
#RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
|
||||
#RewriteRule - - [L]
|
||||
# 5) Mark versioned assets (?v=...) with env flag for caching rules below
|
||||
RewriteCond %{QUERY_STRING} (^|&)v= [NC]
|
||||
RewriteRule ^ - [E=IS_VER:1]
|
||||
</IfModule>
|
||||
|
||||
# --- MIME types (fonts/SVG/ESM) ---
|
||||
# ---------------- MIME types ----------------
|
||||
<IfModule mod_mime.c>
|
||||
AddType font/woff2 .woff2
|
||||
AddType font/woff .woff
|
||||
@@ -37,7 +65,7 @@ RewriteEngine On
|
||||
AddType application/javascript .mjs
|
||||
</IfModule>
|
||||
|
||||
# --- Security headers ---
|
||||
# ---------------- Security headers ----------------
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
@@ -48,54 +76,53 @@ RewriteEngine On
|
||||
Header always set Expect-CT "max-age=86400, enforce"
|
||||
Header always set Cross-Origin-Resource-Policy "same-origin"
|
||||
Header always set X-Permitted-Cross-Domain-Policies "none"
|
||||
# HSTS only when actually on HTTPS
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" "expr=%{HTTPS} == 'on'"
|
||||
|
||||
# CSP (modules, blobs, workers, etc.)
|
||||
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
|
||||
# HSTS only when HTTPS (safe for .htaccess)
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" env=HTTPS
|
||||
|
||||
# CSP — keep this SHA-256 in sync with your inline pre-theme script
|
||||
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
|
||||
</IfModule>
|
||||
|
||||
# --- Caching (query-string based, no env vars needed) ---
|
||||
# ---------------- Caching ----------------
|
||||
<IfModule mod_headers.c>
|
||||
# HTML/PHP: no cache (only if PHP didn’t already set it)
|
||||
# HTML/PHP: no cache
|
||||
<FilesMatch "\.(html?|php)$">
|
||||
Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header setifempty Pragma "no-cache"
|
||||
Header setifempty Expires "0"
|
||||
</FilesMatch>
|
||||
|
||||
# version.js: always non-cacheable
|
||||
# version.js: never cache
|
||||
<FilesMatch "^js/version\.js$">
|
||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</FilesMatch>
|
||||
|
||||
# Unversioned JS/CSS: 1 hour
|
||||
# JS/CSS: long cache if ?v= present, else 1h
|
||||
<FilesMatch "\.(?:m?js|css)$">
|
||||
Header set Cache-Control "public, max-age=3600, must-revalidate" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
|
||||
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
|
||||
Header set Cache-Control "public, max-age=3600, must-revalidate" env=!IS_VER
|
||||
</FilesMatch>
|
||||
|
||||
# Unversioned static (images/fonts): 7 days
|
||||
# Images/fonts: long cache if ?v= present, else 7d
|
||||
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
||||
Header set Cache-Control "public, max-age=604800" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
|
||||
</FilesMatch>
|
||||
|
||||
# Versioned assets (?v=...): 1 year + immutable
|
||||
<FilesMatch "\.(?:m?js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
||||
Header setifempty Cache-Control "public, max-age=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/"
|
||||
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
|
||||
Header set Cache-Control "public, max-age=604800" env=!IS_VER
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
# --- Compression ---
|
||||
# ---------------- Compression ----------------
|
||||
<IfModule mod_brotli.c>
|
||||
BrotliCompressionQuality 5
|
||||
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
|
||||
</IfModule>
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
|
||||
</IfModule>
|
||||
|
||||
# --- Disable TRACE ---
|
||||
# ---------------- Disable TRACE ----------------
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteCond %{REQUEST_METHOD} ^TRACE
|
||||
RewriteRule .* - [F]
|
||||
RewriteRule .* - [F]
|
||||
</IfModule>
|
||||
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();
|
||||
17
public/api/folder/getFolderColors.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||
|
||||
try {
|
||||
$ctl = new FolderController();
|
||||
$ctl->getFolderColors(); // echoes JSON + status codes
|
||||
} catch (Throwable $e) {
|
||||
error_log('getFolderColors failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'Internal server error']);
|
||||
}
|
||||
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),
|
||||
]);
|
||||
17
public/api/folder/saveFolderColor.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||
|
||||
try {
|
||||
$ctl = new FolderController();
|
||||
$ctl->saveFolderColor(); // validates method + CSRF, does ACL, echoes JSON
|
||||
} catch (Throwable $e) {
|
||||
error_log('saveFolderColor failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'Internal server error']);
|
||||
}
|
||||
7
public/api/media/getProgress.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
// public/api/media/getProgress.php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
|
||||
|
||||
$ctl = new MediaController();
|
||||
$ctl->getProgress();
|
||||
7
public/api/media/getViewedMap.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
// public/api/media/getViewedMap.php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
|
||||
|
||||
$ctl = new MediaController();
|
||||
$ctl->getViewedMap();
|
||||
7
public/api/media/updateProgress.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
// public/api/media/updateProgress.php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
|
||||
|
||||
$ctl = new MediaController();
|
||||
$ctl->updateProgress();
|
||||
13
public/api/onlyoffice/callback.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/onlyoffice/callback.php",
|
||||
* summary="ONLYOFFICE save callback",
|
||||
* tags={"ONLYOFFICE"},
|
||||
* @OA\Response(response=200, description="OK / error JSON")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||
(new OnlyOfficeController())->callback();
|
||||
17
public/api/onlyoffice/config.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/onlyoffice/config.php",
|
||||
* summary="Get editor config for a file (signed URLs, callback)",
|
||||
* tags={"ONLYOFFICE"},
|
||||
* @OA\Parameter(name="folder", in="query", @OA\Schema(type="string")),
|
||||
* @OA\Parameter(name="file", in="query", @OA\Schema(type="string")),
|
||||
* @OA\Response(response=200, description="Editor config"),
|
||||
* @OA\Response(response=403, description="Forbidden"),
|
||||
* @OA\Response(response=404, description="Disabled / Not found")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||
(new OnlyOfficeController())->config();
|
||||
15
public/api/onlyoffice/signed-download.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/onlyoffice/signed-download.php",
|
||||
* summary="Serve a signed file blob to ONLYOFFICE",
|
||||
* tags={"ONLYOFFICE"},
|
||||
* @OA\Parameter(name="tok", in="query", required=true, @OA\Schema(type="string")),
|
||||
* @OA\Response(response=200, description="File stream"),
|
||||
* @OA\Response(response=403, description="Signature/expiry invalid")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||
(new OnlyOfficeController())->signedDownload();
|
||||
13
public/api/onlyoffice/status.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/onlyoffice/status.php",
|
||||
* summary="ONLYOFFICE availability & supported extensions",
|
||||
* tags={"ONLYOFFICE"},
|
||||
* @OA\Response(response=200, description="Status JSON")
|
||||
* )
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||
(new OnlyOfficeController())->status();
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 17 KiB |
BIN
public/assets/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/assets/icons/base-1024.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/assets/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/assets/icons/maskable-512.png
Normal file
|
After Width: | Height: | Size: 15 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 |
@@ -1,6 +1,38 @@
|
||||
/* ===========================================================
|
||||
GENERAL STYLES & BASE LAYOUT
|
||||
=========================================================== */
|
||||
/* Reserve stable space for header + main */
|
||||
:root { --header-h: 55px; }
|
||||
.header-container { min-height: var(--header-h); }
|
||||
|
||||
|
||||
img.logo{ width:50px; height:50px; display:block; } /* belt & suspenders for logo sizing */
|
||||
/* Hidden-but-reserved utility (no clicks) */
|
||||
.is-visually-hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* After auth: show app, hide login */
|
||||
|
||||
|
||||
#fr-login-tip {
|
||||
min-height: 40px; /* reserve space */
|
||||
max-width: 520px;
|
||||
margin: 8px auto 0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.main-wrapper{
|
||||
display:flex; /* or grid—flex is fine here */
|
||||
gap:5px;
|
||||
align-items:flex-start;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* GENERAL STYLES */
|
||||
body {
|
||||
@@ -24,12 +56,57 @@ body {
|
||||
padding-left: 4px !important;
|
||||
}@media (min-width: 1300px) {
|
||||
.container-fluid {
|
||||
padding-left: 30px !important;
|
||||
padding-right: 30px !important;
|
||||
padding-left: 20px !important;
|
||||
padding-right: 20px !important;
|
||||
}}
|
||||
@media (max-width: 600px) {
|
||||
.zones-toggle { left: 85px !important; }
|
||||
}
|
||||
|
||||
/* Optional tokens */
|
||||
:root{
|
||||
--filr-accent-500:#008CB4; /* base */
|
||||
--filr-accent-600:#00789A; /* hover */
|
||||
--filr-accent-700:#006882; /* active/border */
|
||||
--filr-accent-ring:rgba(0,140,180,.4);
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.btn-color-folder{
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
background:var(--filr-accent-500);
|
||||
border:1px solid var(--filr-accent-700);
|
||||
color:#fff; /* ensure white text */
|
||||
}
|
||||
.btn-color-folder .material-icons{
|
||||
color:currentColor; /* makes icon white too */
|
||||
}
|
||||
|
||||
.btn-color-folder:hover,
|
||||
.btn-color-folder:focus-visible{
|
||||
background:var(--filr-accent-600);
|
||||
border-color:var(--filr-accent-700);
|
||||
}
|
||||
|
||||
.btn-color-folder:active{
|
||||
background:var(--filr-accent-700);
|
||||
}
|
||||
|
||||
.btn-color-folder:focus-visible{
|
||||
outline:2px solid var(--filr-accent-ring);
|
||||
outline-offset:2px;
|
||||
}
|
||||
|
||||
/* Dark mode: start slightly deeper so it doesn't glow */
|
||||
.dark-mode .btn-color-folder{
|
||||
background:var(--filr-accent-600);
|
||||
border-color:var(--filr-accent-700);
|
||||
color:#fff;
|
||||
}
|
||||
.dark-mode .btn-color-folder:hover,
|
||||
.dark-mode .btn-color-folder:focus-visible{
|
||||
background:var(--filr-accent-700);
|
||||
}
|
||||
/* ===========================================================
|
||||
HEADER & NAVIGATION
|
||||
=========================================================== */
|
||||
@@ -37,11 +114,6 @@ body {
|
||||
/************************************************************/
|
||||
/* FLEXBOX HEADER: LOGO, TITLE, BUTTONS FIXED */
|
||||
/************************************************************/
|
||||
.header-logo .logo {
|
||||
display:block;
|
||||
max-width:100%;
|
||||
height:auto; /* keep aspect ratio; HTML attrs set the intrinsic box */
|
||||
}
|
||||
.btn-login {
|
||||
margin-top: 10px;
|
||||
}/* Color overrides */
|
||||
@@ -65,7 +137,7 @@ body {
|
||||
background-color: #2196F3;
|
||||
transition: background-color 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}body.dark-mode .header-container {
|
||||
}.dark-mode .header-container {
|
||||
background-color: #1f1f1f;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
|
||||
}#darkModeIcon {
|
||||
@@ -77,7 +149,7 @@ body {
|
||||
}.header-logo svg {
|
||||
height: 50px;
|
||||
width: auto;
|
||||
}body.dark-mode header {
|
||||
}.dark-mode header {
|
||||
background-color: #1f1f1f;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
|
||||
}.header-left {
|
||||
@@ -114,7 +186,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;
|
||||
@@ -163,7 +243,7 @@ body {
|
||||
padding: 10px;
|
||||
box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}/* Folder Help Tooltip - Dark Mode */
|
||||
body.dark-mode .folder-help-tooltip {
|
||||
.dark-mode .folder-help-tooltip {
|
||||
background-color: #333 !important;
|
||||
color: #eee !important;
|
||||
border: 1px solid #555 !important;
|
||||
@@ -171,7 +251,7 @@ body {
|
||||
-webkit-text-fill-color: orange !important;
|
||||
color: inherit !important;
|
||||
padding-right: 10px !important;
|
||||
}body.dark-mode #folderHelpBtn i.material-icons.folder-help-icon {
|
||||
}.dark-mode #folderHelpBtn i.material-icons.folder-help-icon {
|
||||
-webkit-text-fill-color: #ffa500 !important;
|
||||
padding-right: 10px !important;
|
||||
}/************************************************************/
|
||||
@@ -221,8 +301,8 @@ body {
|
||||
.material-icons.gallery-icon {
|
||||
color: black;
|
||||
margin-right: 5px;
|
||||
}body.dark-mode .material-icons.folder-icon,
|
||||
body.dark-mode .material-icons.gallery-icon {
|
||||
}.dark-mode .material-icons.folder-icon,
|
||||
.dark-mode .material-icons.gallery-icon {
|
||||
color: white;
|
||||
margin-right: 5px;
|
||||
}.remove-file-btn {
|
||||
@@ -253,23 +333,23 @@ body {
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
}body.dark-mode #loginForm {
|
||||
}.dark-mode #loginForm {
|
||||
background-color: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.2);
|
||||
}body.dark-mode #loginForm input {
|
||||
}.dark-mode #loginForm input {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
border: 1px solid #555;
|
||||
}body.dark-mode #loginForm label {
|
||||
}.dark-mode #loginForm label {
|
||||
color: #ddd;
|
||||
}body.dark-mode #loginForm button {
|
||||
}.dark-mode #loginForm button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
}body.dark-mode #loginForm button:hover {
|
||||
}.dark-mode #loginForm button:hover {
|
||||
background-color: #0056b3;
|
||||
}/* ===========================================================
|
||||
CARDS & MODALS
|
||||
@@ -292,7 +372,7 @@ body {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}/* Override modal content for dark mode */
|
||||
body.dark-mode #restoreFilesModal .modal-content {
|
||||
.dark-mode #restoreFilesModal .modal-content {
|
||||
background: #2c2c2c !important;
|
||||
border: 1px solid #555 !important;
|
||||
color: #f0f0f0;
|
||||
@@ -376,7 +456,7 @@ body {
|
||||
transform: translate(-50%, -70%);
|
||||
}}
|
||||
|
||||
body.dark-mode .modal .modal-content {
|
||||
.dark-mode .modal .modal-content {
|
||||
background-color: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
border-color: #444;
|
||||
@@ -405,10 +485,10 @@ body {
|
||||
background-color: #ff4d4d;
|
||||
box-shadow: 0px 0px 6px rgba(255, 77, 77, 0.8);
|
||||
transform: scale(1.05);
|
||||
}body.dark-mode .editor-close-btn {
|
||||
}.dark-mode .editor-close-btn {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: #ff6666;
|
||||
}body.dark-mode .editor-close-btn:hover {
|
||||
}.dark-mode .editor-close-btn:hover {
|
||||
background-color: #ff6666;
|
||||
color: #000;
|
||||
}/* Editor Modal */
|
||||
@@ -434,7 +514,7 @@ body {
|
||||
width: 100% !important;
|
||||
resize: none !important;
|
||||
overflow: auto !important;
|
||||
}body.dark-mode .editor-modal {
|
||||
}.dark-mode .editor-modal {
|
||||
background-color: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
border-color: #444;
|
||||
@@ -459,7 +539,7 @@ body {
|
||||
}.editor-title {
|
||||
margin: 0;
|
||||
line-height: 33px;
|
||||
}body.dark-mode .editor-header {
|
||||
}.dark-mode .editor-header {
|
||||
background-color: #2c2c2c;
|
||||
}@media (max-width: 600px) {
|
||||
.editor-title {
|
||||
@@ -527,9 +607,9 @@ body {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}body.dark-mode .material-icons.pauseResumeBtn {
|
||||
}.dark-mode .material-icons.pauseResumeBtn {
|
||||
color: white !important;
|
||||
}body.dark-mode .material-icons.pauseResumeBtn:hover {
|
||||
}.dark-mode .material-icons.pauseResumeBtn:hover {
|
||||
background-color: rgba(255, 215, 0, 0.3);
|
||||
color: #fff;
|
||||
}body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
|
||||
@@ -632,15 +712,15 @@ body {
|
||||
}#createBtn {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}body.dark-mode .dropdown-menu {
|
||||
}.dark-mode .dropdown-menu {
|
||||
background-color: #2c2c2c !important;
|
||||
border-color: #444 !important;
|
||||
color: #e0e0e0!important;
|
||||
}body.dark-mode .dropdown-menu .dropdown-item {
|
||||
}.dark-mode .dropdown-menu .dropdown-item {
|
||||
color: #e0e0e0 !important;
|
||||
}.dropdown-item:hover {
|
||||
background-color: rgba(0,0,0,0.05);
|
||||
}body.dark-mode .dropdown-item:hover {
|
||||
}.dark-mode .dropdown-item:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}#fileList button.edit-btn {
|
||||
background-color: #007bff;
|
||||
@@ -661,7 +741,7 @@ body {
|
||||
background-color: transparent;
|
||||
}#fileList table tr:hover {
|
||||
background-color: #e0e0e0;
|
||||
}body.dark-mode #fileList table tr:hover {
|
||||
}.dark-mode #fileList table tr:hover {
|
||||
background-color: #444;
|
||||
}#fileListTitle {
|
||||
white-space: normal !important;
|
||||
@@ -679,7 +759,7 @@ body {
|
||||
box-shadow: none;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
}body.dark-mode #fileList table tr {
|
||||
}.dark-mode #fileList table tr {
|
||||
box-shadow: none;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
@@ -763,17 +843,20 @@ body {
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}#loginForm,
|
||||
}
|
||||
#uploadForm {
|
||||
display: none;
|
||||
}.folder-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.folder-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding-top: 10px;
|
||||
}@media (min-width: 600px) and (max-width: 992px) {
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
white-space: normal;
|
||||
margin: 0; /* no hacks needed */
|
||||
}
|
||||
@media (min-width: 600px) and (max-width: 992px) {
|
||||
.folder-actions {
|
||||
white-space: nowrap;
|
||||
}}
|
||||
@@ -786,10 +869,8 @@ body {
|
||||
}.folder-actions .material-icons {
|
||||
font-size: 24px;
|
||||
vertical-align: -2px;
|
||||
}.folder-actions .btn + .btn {
|
||||
margin-left: 6px;
|
||||
}.folder-actions .btn {
|
||||
padding: 10px 12px;
|
||||
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.1;
|
||||
border-radius: 6px;
|
||||
@@ -799,7 +880,7 @@ body {
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
will-change: transform;
|
||||
}.folder-actions .material-icons {
|
||||
font-size: 24px;
|
||||
font-size: 20px;
|
||||
vertical-align: -2px;
|
||||
transition: transform 120ms ease;
|
||||
}.folder-actions .btn:hover,
|
||||
@@ -824,7 +905,7 @@ body {
|
||||
color: #fff;
|
||||
}.row-selected {
|
||||
background-color: #f2f2f2 !important;
|
||||
}body.dark-mode .row-selected {
|
||||
}.dark-mode .row-selected {
|
||||
background-color: #444 !important;
|
||||
color: #fff !important;
|
||||
}.custom-prev-next-btn {
|
||||
@@ -838,11 +919,11 @@ body {
|
||||
cursor: pointer;
|
||||
}.custom-prev-next-btn:hover:not(:disabled) {
|
||||
background-color: #d5d5d5;
|
||||
}body.dark-mode .custom-prev-next-btn {
|
||||
}.dark-mode .custom-prev-next-btn {
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
}body.dark-mode .custom-prev-next-btn:hover:not(:disabled) {
|
||||
}.dark-mode .custom-prev-next-btn:hover:not(:disabled) {
|
||||
background-color: #555;
|
||||
}#customToast {
|
||||
position: fixed;
|
||||
@@ -879,6 +960,10 @@ body {
|
||||
line-height: 1 !important;
|
||||
vertical-align: middle !important;
|
||||
}#fileListContainer {
|
||||
border: 1px solid #e0e0e0;
|
||||
background: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
max-width: 100%;
|
||||
padding-bottom: 10px !important;
|
||||
padding-left: 5px !important;
|
||||
@@ -889,7 +974,7 @@ body {
|
||||
width: 99%;
|
||||
}}
|
||||
|
||||
body.dark-mode #fileListContainer {
|
||||
.dark-mode #fileListContainer {
|
||||
background-color: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #444;
|
||||
@@ -938,7 +1023,7 @@ body {
|
||||
align-items: stretch;
|
||||
}.file-list-actions .action-btn {
|
||||
width: 100%;
|
||||
height: 10px !important;
|
||||
|
||||
}.modal-content {
|
||||
width: 95%;
|
||||
margin: 20% auto;
|
||||
@@ -965,7 +1050,7 @@ body {
|
||||
#copySelectedBtn:hover,
|
||||
#moveSelectedBtn:hover,
|
||||
#downloadZipBtn:hover,
|
||||
#extractZipBtn:hover
|
||||
#extractZipBtn:hover,
|
||||
#customChooseBtn:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,.12);
|
||||
@@ -1093,7 +1178,7 @@ body {
|
||||
border-radius: 4px;
|
||||
}.folder-tree {
|
||||
list-style-type: none;
|
||||
padding-left: 10px;
|
||||
padding-left: 5px;
|
||||
margin: 0;
|
||||
}.folder-tree.collapsed {
|
||||
display: none;
|
||||
@@ -1110,9 +1195,10 @@ body {
|
||||
text-align: right;
|
||||
}.folder-indent-placeholder {
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
width: 5px;
|
||||
}#folderTreeContainer {
|
||||
display: block;
|
||||
margin-left: 10px;
|
||||
}.folder-option {
|
||||
cursor: pointer;
|
||||
}.folder-option:hover {
|
||||
@@ -1122,12 +1208,12 @@ body {
|
||||
background-color: #d0d0d0;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
}body.dark-mode .folder-option.selected {
|
||||
}.dark-mode .folder-option.selected {
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
}body.dark-mode .folder-option:hover {
|
||||
}.dark-mode .folder-option:hover {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
padding: 2px 4px;
|
||||
@@ -1167,7 +1253,7 @@ body {
|
||||
display: inline-flex !important;
|
||||
}}
|
||||
|
||||
body.dark-mode .image-preview-modal-content {
|
||||
.dark-mode .image-preview-modal-content {
|
||||
background: #2c2c2c;
|
||||
border-color: #444;
|
||||
}.image-modal-img {
|
||||
@@ -1204,13 +1290,13 @@ body {
|
||||
width: 600px !important;
|
||||
max-width: 90vw !important;
|
||||
/* ensures it doesn't exceed the viewport width */
|
||||
}body.dark-mode .close-image-modal {
|
||||
}.dark-mode .close-image-modal {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: #ff6666;
|
||||
}body.dark-mode .close-image-modal:hover {
|
||||
}.dark-mode .close-image-modal:hover {
|
||||
background-color: #ff6666;
|
||||
color: #000;
|
||||
}body.dark-mode .image-preview-modal-content {
|
||||
}.dark-mode .image-preview-modal-content {
|
||||
background: #2c2c2c;
|
||||
border-color: #444;
|
||||
}.page-indicator {
|
||||
@@ -1223,7 +1309,7 @@ body {
|
||||
margin-right: 0;
|
||||
margin-left: 0;
|
||||
font-size: 32px;
|
||||
}body.dark-mode .file-icon {
|
||||
}.dark-mode .file-icon {
|
||||
color: white;
|
||||
}.bottom-select {
|
||||
display: inline-block;
|
||||
@@ -1321,36 +1407,36 @@ body {
|
||||
}/* ===========================================================
|
||||
DARK MODE STYLES
|
||||
=========================================================== */
|
||||
body.dark-mode {
|
||||
.dark-mode {
|
||||
background-color: #121212;
|
||||
color: #e0e0e0;
|
||||
}body.dark-mode .container {
|
||||
}.dark-mode .container {
|
||||
background-color: transparent !important;
|
||||
}body.dark-mode .btn-primary {
|
||||
}.dark-mode .btn-primary {
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
border-color: #007bff;
|
||||
}body.dark-mode .btn-secondary {
|
||||
}.dark-mode .btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: #fff;
|
||||
border-color: #6c757d;
|
||||
}body.dark-mode .btn-danger {
|
||||
}.dark-mode .btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: #fff;
|
||||
border-color: #dc3545;
|
||||
}body.dark-mode .modal .modal-content,
|
||||
body.dark-mode .editor-modal {
|
||||
}.dark-mode .modal .modal-content,
|
||||
.dark-mode .editor-modal {
|
||||
background-color: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #444;
|
||||
}body.dark-mode table {
|
||||
}.dark-mode table {
|
||||
background-color: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
}body.dark-mode table tr:hover {
|
||||
}.dark-mode table tr:hover {
|
||||
background-color: #444;
|
||||
}body.dark-mode #uploadProgressContainer .progress {
|
||||
}.dark-mode #uploadProgressContainer .progress {
|
||||
background-color: #333;
|
||||
}body.dark-mode #uploadProgressContainer .progress-bar {
|
||||
}.dark-mode #uploadProgressContainer .progress-bar {
|
||||
background-color: #007bff;
|
||||
color: #e0e0e0;
|
||||
}.dark-mode-toggle {
|
||||
@@ -1367,10 +1453,10 @@ body {
|
||||
background-color: rgba(255, 255, 255, 0.15) !important;
|
||||
}.dark-mode-toggle:active {
|
||||
background-color: rgba(255, 255, 255, 0.25) !important;
|
||||
}body.dark-mode .dark-mode-toggle {
|
||||
}.dark-mode .dark-mode-toggle {
|
||||
background-color: transparent !important;
|
||||
color: white !important;
|
||||
}body.dark-mode .dark-mode-toggle:hover {
|
||||
}.dark-mode .dark-mode-toggle:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15) !important;
|
||||
}.dark-mode-toggle:focus {
|
||||
outline: none !important;
|
||||
@@ -1397,29 +1483,29 @@ body {
|
||||
}.folder-help-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}body.dark-mode .folder-help-details {
|
||||
}.dark-mode .folder-help-details {
|
||||
color: #ddd;
|
||||
background-color: #2c2c2c;
|
||||
border-color: #444;
|
||||
}body.dark-mode .folder-help-summary {
|
||||
}.dark-mode .folder-help-summary {
|
||||
color: #ddd;
|
||||
background: #2c2c2c;
|
||||
}body.dark-mode .folder-help-icon {
|
||||
}.dark-mode .folder-help-icon {
|
||||
color: #f6a72c;
|
||||
font-size: 20px;
|
||||
}body.dark-mode .CodeMirror {
|
||||
}.dark-mode .CodeMirror {
|
||||
background: #1e1e1e !important;
|
||||
color: #ffffff !important;
|
||||
}body.dark-mode .CodeMirror-cursor {
|
||||
}.dark-mode .CodeMirror-cursor {
|
||||
border-left: 2px solid #ffffff !important;
|
||||
}body.dark-mode .CodeMirror-gutters {
|
||||
}.dark-mode .CodeMirror-gutters {
|
||||
background: #252526 !important;
|
||||
border-right: 1px solid #444 !important;
|
||||
}body.dark-mode .CodeMirror-linenumber {
|
||||
}.dark-mode .CodeMirror-linenumber {
|
||||
color: #aaaaaa !important;
|
||||
}body.dark-mode .CodeMirror-selected {
|
||||
}.dark-mode .CodeMirror-selected {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
}body.dark-mode .CodeMirror-matchingbracket {
|
||||
}.dark-mode .CodeMirror-matchingbracket {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
border-bottom: 1px solid #ffffff !important;
|
||||
}.zoom_in,
|
||||
@@ -1454,7 +1540,7 @@ body {
|
||||
}.drop-hover {
|
||||
background-color: #e0e0e0;
|
||||
border: 1px dashed #666;
|
||||
}body.dark-mode .drop-hover {
|
||||
}.dark-mode .drop-hover {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
border-bottom: 1px dashed #ffffff !important;
|
||||
}#restoreFilesList li {
|
||||
@@ -1466,36 +1552,43 @@ body {
|
||||
transform: translateY(-3px) !important;
|
||||
}#restoreFilesList li label {
|
||||
margin-left: 8px !important;
|
||||
}body.dark-mode #fileContextMenu {
|
||||
}.dark-mode #fileContextMenu {
|
||||
background-color: #2c2c2c !important;
|
||||
border: 1px solid #555 !important;
|
||||
color: #e0e0e0 !important;
|
||||
}body.dark-mode #fileContextMenu div {
|
||||
}.dark-mode #fileContextMenu div {
|
||||
color: #e0e0e0 !important;
|
||||
}#folderContextMenu {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
}body.dark-mode #folderContextMenu {
|
||||
}.dark-mode #folderContextMenu {
|
||||
background-color: #2c2c2c;
|
||||
border-color: #555;
|
||||
color: #e0e0e0;
|
||||
}.main-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}.drop-target-sidebar {
|
||||
display: none;
|
||||
width: 50px;
|
||||
transition: width 0.3s ease;
|
||||
background-color: #f8f9fa;
|
||||
border-right: 2px dashed #1565C0;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
}@media (min-width: 769px) {
|
||||
.drop-target-sidebar {
|
||||
display: block;
|
||||
}}
|
||||
.drop-target-sidebar.active {
|
||||
.drop-target-sidebar.active,
|
||||
.drag-header.active {
|
||||
width: 350px;
|
||||
}.main-column {
|
||||
height: 750px;
|
||||
}
|
||||
/* 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 {
|
||||
@@ -1563,13 +1656,12 @@ body {
|
||||
}#sidebarDropArea,
|
||||
#uploadFolderRow {
|
||||
background-color: transparent;
|
||||
}#sidebarDropArea {
|
||||
display: none;
|
||||
}body.dark-mode #sidebarDropArea,
|
||||
body.dark-mode #uploadFolderRow {
|
||||
}
|
||||
.dark-mode #sidebarDropArea,
|
||||
.dark-mode #uploadFolderRow {
|
||||
background-color: transparent;
|
||||
}body.dark-mode #sidebarDropArea.highlight,
|
||||
body.dark-mode #uploadFolderRow.highlight {
|
||||
}.dark-mode #sidebarDropArea.highlight,
|
||||
.dark-mode #uploadFolderRow.highlight {
|
||||
background-color: #333;
|
||||
border: 2px dashed #555;
|
||||
color: #fff;
|
||||
@@ -1579,8 +1671,6 @@ body {
|
||||
border: none !important;
|
||||
}.dragging:focus {
|
||||
outline: none;
|
||||
}#sidebarDropArea > .card {
|
||||
margin-bottom: 1rem;
|
||||
}.card {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
@@ -1588,7 +1678,7 @@ body {
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}body.dark-mode .card {
|
||||
}.dark-mode .card {
|
||||
background-color: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #444;
|
||||
@@ -1598,6 +1688,7 @@ body {
|
||||
}.custom-folder-card-body {
|
||||
padding-top: 5px !important;
|
||||
padding-right: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
}#addUserModal,
|
||||
#removeUserModal {
|
||||
z-index: 5000 !important;
|
||||
@@ -1606,17 +1697,17 @@ body {
|
||||
}.admin-panel-content {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}body.dark-mode .admin-panel-content {
|
||||
}.dark-mode .admin-panel-content {
|
||||
background: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #444;
|
||||
}body.dark-mode .admin-panel-content input,
|
||||
body.dark-mode .admin-panel-content select,
|
||||
body.dark-mode .admin-panel-content textarea {
|
||||
}.dark-mode .admin-panel-content input,
|
||||
.dark-mode .admin-panel-content select,
|
||||
.dark-mode .admin-panel-content textarea {
|
||||
background: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #555;
|
||||
}body.dark-mode .admin-panel-content label {
|
||||
}.dark-mode .admin-panel-content label {
|
||||
color: #e0e0e0;
|
||||
}#openChangePasswordModalBtn {
|
||||
width: max-content;
|
||||
@@ -1637,7 +1728,7 @@ body {
|
||||
color: var(--download-spinner-color, #000);
|
||||
}body:not(.dark-mode) {
|
||||
--download-spinner-color: #000;
|
||||
}body.dark-mode {
|
||||
}.dark-mode {
|
||||
--download-spinner-color: #fff;
|
||||
}.rise-effect {
|
||||
transform: translateY(-20px);
|
||||
@@ -1672,13 +1763,14 @@ body {
|
||||
background-color: transparent;
|
||||
transition: width 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}body.dark-mode .header-drop-zone.drag-active {
|
||||
}.dark-mode .header-drop-zone.drag-active {
|
||||
background-color: #333;
|
||||
border: 2px dashed #555;
|
||||
color: #fff;
|
||||
}.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 {
|
||||
@@ -1703,16 +1795,16 @@ body {
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}body.dark-mode #fileSummary {
|
||||
}.dark-mode #fileSummary {
|
||||
color: white;
|
||||
}#searchIcon {
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
}body.dark-mode #searchIcon {
|
||||
}.dark-mode #searchIcon {
|
||||
background-color: #444;
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
}body.dark-mode #searchInput {
|
||||
}.dark-mode #searchInput {
|
||||
background-color: #333;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #555;
|
||||
@@ -1737,11 +1829,11 @@ body {
|
||||
.btn-icon:focus {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
outline: none;
|
||||
}body.dark-mode .btn-icon .material-icons,
|
||||
body.dark-mode #searchIcon .material-icons {
|
||||
}.dark-mode .btn-icon .material-icons,
|
||||
.dark-mode #searchIcon .material-icons {
|
||||
color: #fff;
|
||||
}body.dark-mode .btn-icon:hover,
|
||||
body.dark-mode .btn-icon:focus {
|
||||
}.dark-mode .btn-icon:hover,
|
||||
.dark-mode .btn-icon:focus {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}.user-dropdown {
|
||||
position: relative;
|
||||
@@ -1772,12 +1864,12 @@ body {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.25rem;
|
||||
}body.dark-mode .user-dropdown .user-menu {
|
||||
}.dark-mode .user-dropdown .user-menu {
|
||||
background: #2c2c2c;
|
||||
border-color: #444;
|
||||
}body.dark-mode .user-dropdown .user-menu .item {
|
||||
}.dark-mode .user-dropdown .user-menu .item {
|
||||
color: #e0e0e0;
|
||||
}body.dark-mode .user-dropdown .user-menu .item:hover {
|
||||
}.dark-mode .user-dropdown .user-menu .item:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}.user-dropdown .dropdown-username {
|
||||
margin: 0 8px;
|
||||
@@ -1814,7 +1906,7 @@ body {
|
||||
}:root {
|
||||
--perm-caret: #444;
|
||||
}/* light */
|
||||
body.dark-mode {
|
||||
.dark-mode {
|
||||
--perm-caret: #ccc;
|
||||
}/* dark */
|
||||
|
||||
@@ -1827,7 +1919,7 @@ body {
|
||||
background-color 160ms cubic-bezier(.2,.0,.2,1);
|
||||
}:root {
|
||||
--toggle-icon-color: #333;
|
||||
}body.dark-mode {
|
||||
}.dark-mode {
|
||||
--toggle-icon-color: #eee;
|
||||
}#zonesToggleFloating .material-icons,
|
||||
#zonesToggleFloating .material-icons-outlined,
|
||||
@@ -1872,4 +1964,211 @@ body {
|
||||
background: #fafafa;
|
||||
border-color: #e2e2e2;
|
||||
}
|
||||
|
||||
/* media modal polish */
|
||||
.media-modal { background: var(--panel-bg, #121212); }
|
||||
.media-header-bar .btn { padding: 6px 10px; }
|
||||
.gallery-nav-btn { color: #fff; opacity: 0.85; }
|
||||
.gallery-nav-btn:hover { opacity: 1; transform: scale(1.05); }
|
||||
|
||||
/* badges */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,.15);
|
||||
background: rgba(255,255,255,.08);
|
||||
color: #fff;
|
||||
}
|
||||
.status-badge.watched {
|
||||
border-color: rgba(34,197,94,.45); /* green-ish */
|
||||
background: rgba(34,197,94,.15);
|
||||
}
|
||||
.status-badge.progress {
|
||||
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 .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);
|
||||
}
|
||||
|
||||
/* variables will be set inline per .folder-option when user colors a folder */
|
||||
#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); }
|
||||
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -2,65 +2,45 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FileRise</title>
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="icon" type="image/png" href="/assets/logo.png">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||
|
||||
<!-- App meta -->
|
||||
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
||||
<meta name="csrf-token" content="">
|
||||
<meta name="share-url" content="">
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
||||
<meta name="theme-color" content="#0b5ed7">
|
||||
|
||||
<!-- Minimal critical CSS only (keeps CSP clean, no inline JS) -->
|
||||
<style>
|
||||
.main-wrapper{display:none}
|
||||
#loadingOverlay{position:fixed;inset:0;background:var(--bg-color,#fff);z-index:9999;display:flex;align-items:center;justify-content:center}
|
||||
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
|
||||
<style id="pretheme-css">
|
||||
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
|
||||
</style>
|
||||
|
||||
<!-- CSS: preload, then promote via tiny external JS (no inline onload) -->
|
||||
<link rel="preload" as="style" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||
<link rel="preload" as="style" href="/css/styles.css?v={{APP_QVER}}">
|
||||
|
||||
<!-- Fonts: preload only those used above the fold -->
|
||||
<link rel="preload" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" as="font" type="font/woff2" crossorigin>
|
||||
<!-- Do NOT preload material icons unless needed above the fold -->
|
||||
|
||||
<!-- Non-blocking stylesheet promotion (external to satisfy CSP) -->
|
||||
<script src="/js/defer-css.js?v={{APP_QVER}}" defer></script>
|
||||
<!-- 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}}">
|
||||
|
||||
|
||||
<!-- Base CSS as a fallback if JS is disabled -->
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||
</noscript>
|
||||
|
||||
<!-- Preload font CSS (non-blocking) -->
|
||||
<link rel="preload" as="style" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||
<link rel="preload" as="style" href="/css/vendor/material-icons.css?v={{APP_QVER}}">
|
||||
|
||||
<!-- Vendor JS (keep defer; they’re not modules) -->
|
||||
<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="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 -->
|
||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||
|
||||
<!-- Fonts (ok to keep as real preloads) -->
|
||||
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
||||
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
||||
|
||||
<!-- Vendor & version (deferred) -->
|
||||
<script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}" defer></script>
|
||||
|
||||
<!-- IMPORTANT: Remove CodeMirror here; lazy-load it inside your editor route/module. -->
|
||||
|
||||
<!-- Version marker (non-blocking) -->
|
||||
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
|
||||
|
||||
|
||||
<!-- App entry: start fetching early, execute after parse -->
|
||||
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}">
|
||||
<script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
|
||||
|
||||
<!-- App entry -->
|
||||
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="appRoot" style="visibility:hidden">
|
||||
<header class="header-container">
|
||||
|
||||
<div class="header-left">
|
||||
<a href="index.html">
|
||||
<div class="header-logo">
|
||||
@@ -68,19 +48,21 @@
|
||||
src="/assets/logo.svg?v={{APP_QVER}}"
|
||||
alt="FileRise"
|
||||
class="logo"
|
||||
width="50" height="50"
|
||||
width="50"
|
||||
height="50"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="header-title">
|
||||
<h1 data-i18n-key="header_title">FileRise</h1>
|
||||
<h1>FileRise</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;">
|
||||
<!-- Your header drop zone -->
|
||||
|
||||
<div id="headerDropArea" class="header-drop-zone"></div>
|
||||
<div class="header-buttons">
|
||||
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
||||
@@ -115,7 +97,7 @@
|
||||
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
|
||||
<i class="material-icons">person_remove</i>
|
||||
</button>
|
||||
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode">
|
||||
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode" hidden>
|
||||
<span class="material-icons" id="darkModeIcon">
|
||||
dark_mode
|
||||
</span>
|
||||
@@ -124,15 +106,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<div id="loadingOverlay"></div>
|
||||
|
||||
<!-- Custom Toast Container -->
|
||||
<div id="customToast"></div>
|
||||
<div id="hiddenCardsContainer" style="display:none;"></div>
|
||||
<main id="main">
|
||||
<main id="main" hidden>
|
||||
<div class="row mt-4" id="loginForm">
|
||||
<div class="col-12">
|
||||
<div id="loginBox" class="login-box">
|
||||
<div id="fr-login-tip" class="alert alert-info login-hint" role="status" aria-live="polite" style="display:none;"></div>
|
||||
|
||||
<form id="authForm" method="post">
|
||||
<div class="form-group">
|
||||
<label for="loginUsername" data-i18n-key="user">User:</label>
|
||||
@@ -158,13 +143,14 @@
|
||||
HTTP
|
||||
Login</a>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
|
||||
<div class="main-wrapper">
|
||||
<div class="main-wrapper" hidden>
|
||||
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
|
||||
<div id="sidebarDropArea" class="drop-target-sidebar"></div>
|
||||
<!-- Main Column -->
|
||||
@@ -266,6 +252,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="colorFolderBtn" class="btn btn-color-folder ml-2" data-i18n-title="color_folder" title="Color folder">
|
||||
<i class="material-icons">palette</i>
|
||||
</button>
|
||||
|
||||
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
|
||||
<i class="material-icons">share</i>
|
||||
@@ -366,6 +355,10 @@
|
||||
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||
<span data-i18n-key="create_folder">Create folder</span>
|
||||
</li>
|
||||
<li id="uploadOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||
<span data-i18n-key="upload">Upload file(s)</span>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Create File Modal -->
|
||||
@@ -505,7 +498,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div id="uploadModal" class="modal" style="display:none;">
|
||||
<div class="modal-content" style="max-width:900px;width:92vw;">
|
||||
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h3 style="margin:0;">Upload</h3>
|
||||
<span id="closeUploadModal" class="editor-close-btn" role="button" aria-label="Close">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- we will MOVE #uploadCard into here while open -->
|
||||
<div id="uploadModalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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>`
|
||||
: '';
|
||||
@@ -170,9 +170,9 @@ async function safeJson(res) {
|
||||
max-width: none !important;
|
||||
}
|
||||
}
|
||||
body.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
|
||||
body.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
|
||||
body.dark-mode .form-control::placeholder { color:#888; }
|
||||
.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
|
||||
.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
|
||||
.dark-mode .form-control::placeholder { color:#888; }
|
||||
|
||||
.section-header {
|
||||
background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:4px; font-weight:bold;
|
||||
@@ -181,8 +181,8 @@ async function safeJson(res) {
|
||||
.section-header:first-of-type { margin-top:0; }
|
||||
.section-header.collapsed .material-icons { transform:rotate(-90deg); }
|
||||
.section-header .material-icons { transition:transform .3s; color:#444; }
|
||||
body.dark-mode .section-header { background:#3a3a3a; color:#eee; }
|
||||
body.dark-mode .section-header .material-icons { color:#ccc; }
|
||||
.dark-mode .section-header { background:#3a3a3a; color:#eee; }
|
||||
.dark-mode .section-header .material-icons { color:#ccc; }
|
||||
|
||||
.section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; }
|
||||
|
||||
@@ -193,7 +193,7 @@ async function safeJson(res) {
|
||||
border:2px solid transparent; transition:all .3s;
|
||||
}
|
||||
#adminPanelModal .editor-close-btn:hover { color:#fff; background:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); }
|
||||
body.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; }
|
||||
.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; }
|
||||
|
||||
.action-row { display:flex; justify-content:space-between; margin-top:15px; }
|
||||
|
||||
@@ -210,7 +210,7 @@ async function safeJson(res) {
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
body.dark-mode .folder-access-list { border-color:#555; }
|
||||
.dark-mode .folder-access-list { border-color:#555; }
|
||||
|
||||
.folder-access-header,
|
||||
.folder-access-row {
|
||||
@@ -228,7 +228,7 @@ async function safeJson(res) {
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.12);
|
||||
}
|
||||
body.dark-mode .folder-access-header { background:#2c2c2c; }
|
||||
.dark-mode .folder-access-header { background:#2c2c2c; }
|
||||
|
||||
.folder-access-row { border-bottom: 1px solid rgba(0,0,0,0.06); }
|
||||
.folder-access-row:last-child { border-bottom: none; }
|
||||
@@ -257,8 +257,8 @@ async function safeJson(res) {
|
||||
color: #2064ff;
|
||||
margin-left: 6px;
|
||||
}
|
||||
body.dark-mode .inherited-row { background: rgba(32,132,255,0.12); }
|
||||
body.dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; }
|
||||
.dark-mode .inherited-row { background: rgba(32,132,255,0.12); }
|
||||
.dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
|
||||
@@ -274,7 +274,7 @@ async function safeJson(res) {
|
||||
/* nicer thin scrollbar (supported browsers) */
|
||||
.folder-cell::-webkit-scrollbar{ height:8px; }
|
||||
.folder-cell::-webkit-scrollbar-thumb{ background:rgba(0,0,0,.25); border-radius:4px; }
|
||||
body.dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); }
|
||||
.dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); }
|
||||
|
||||
/* Badge now doesn't clip; let the wrapper handle scroll */
|
||||
.folder-badge{
|
||||
@@ -491,6 +491,7 @@ export function openAdminPanel() {
|
||||
{ id: "headerSettings", label: t("header_settings") },
|
||||
{ id: "loginOptions", label: t("login_options") },
|
||||
{ id: "webdav", label: "WebDAV Access" },
|
||||
{ id: "onlyoffice", label: "ONLYOFFICE" },
|
||||
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
|
||||
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
|
||||
{ id: "shareLinks", label: t("manage_shared_links") },
|
||||
@@ -514,7 +515,7 @@ export function openAdminPanel() {
|
||||
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
|
||||
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
|
||||
|
||||
["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks", "sponsor"]
|
||||
["userManagement", "headerSettings", "loginOptions", "webdav", "onlyoffice", "upload", "oidc", "shareLinks", "sponsor"]
|
||||
.forEach(id => {
|
||||
document.getElementById(id + "Header")
|
||||
.addEventListener("click", () => toggleSection(id));
|
||||
@@ -574,6 +575,312 @@ export function openAdminPanel() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// ONLYOFFICE Content
|
||||
const hasOOSecret = !!(config.onlyoffice && config.onlyoffice.hasJwtSecret);
|
||||
window.__HAS_OO_SECRET = hasOOSecret;
|
||||
document.getElementById("onlyofficeContent").innerHTML = `
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="ooEnabled" />
|
||||
<label for="ooEnabled">Enable ONLYOFFICE integration</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ooDocsOrigin">Document Server Origin:</label>
|
||||
<input type="url" id="ooDocsOrigin" class="form-control" placeholder="e.g. https://docs.example.com" />
|
||||
<small class="text-muted">Must be reachable by your browser (for API.js) and by FileRise (for callbacks). Avoid “localhost”.</small>
|
||||
</div>
|
||||
|
||||
${renderMaskedInput({ id: "ooJwtSecret", label: "JWT Secret", hasValue: hasOOSecret, isSecret: true })}
|
||||
`;
|
||||
|
||||
wireReplaceButtons(document.getElementById("onlyofficeContent"));
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// --- Test ONLYOFFICE block ---
|
||||
const testBox = document.createElement("div");
|
||||
testBox.className = "card";
|
||||
testBox.style.marginTop = "12px";
|
||||
testBox.innerHTML = `
|
||||
<div class="card-body">
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
|
||||
<strong>Test ONLYOFFICE connection</strong>
|
||||
<button type="button" id="ooTestBtn" class="btn btn-sm btn-primary">Run tests</button>
|
||||
<span id="ooTestSpinner" style="display:none;">⏳</span>
|
||||
</div>
|
||||
<ul id="ooTestResults" class="list-unstyled" style="margin:0;"></ul>
|
||||
<small class="text-muted">These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding.</small>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("onlyofficeContent").appendChild(testBox);
|
||||
|
||||
// Util: tiny UI helpers for results
|
||||
function ooRow(label, status, detail = "") {
|
||||
const li = document.createElement("li");
|
||||
li.style.margin = "6px 0";
|
||||
const icon = status === "ok" ? "✅" : status === "warn" ? "⚠️" : "❌";
|
||||
li.innerHTML = `<span style="min-width:1.2em;display:inline-block">${icon}</span> <strong>${label}</strong>${detail ? ` — <span>${detail}</span>` : ""}`;
|
||||
return li;
|
||||
}
|
||||
function ooClear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
|
||||
|
||||
// --- ONLYOFFICE URL sanitizers ---
|
||||
function getTrustedDocsOrigin(raw) {
|
||||
try {
|
||||
const u = new URL(String(raw || "").trim());
|
||||
if (!/^https?:$/.test(u.protocol)) return null; // only http/https
|
||||
if (u.username || u.password) return null; // no creds in URL
|
||||
return u.origin; // scheme://host[:port]
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildOnlyOfficeApiUrl(origin) {
|
||||
// fixed path; caller already validated/normalized origin
|
||||
const u = new URL('/web-apps/apps/api/documents/api.js', origin);
|
||||
u.searchParams.set('probe', String(Date.now()));
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
|
||||
// Probes that don’t explode your state
|
||||
async function ooProbeScript(docsOrigin) {
|
||||
return new Promise(resolve => {
|
||||
const base = getTrustedDocsOrigin(docsOrigin);
|
||||
if (!base) { resolve({ ok: false }); return; }
|
||||
|
||||
const src = buildOnlyOfficeApiUrl(base);
|
||||
const s = document.createElement('script');
|
||||
s.id = 'ooProbeScript';
|
||||
s.async = true;
|
||||
s.src = src;
|
||||
|
||||
// If you set a CSP nonce in a <meta name="csp-nonce" content="...">, attach it:
|
||||
const nonce = document.querySelector('meta[name="csp-nonce"]')?.content;
|
||||
if (nonce) s.setAttribute('nonce', nonce);
|
||||
|
||||
const cleanup = () => { try { s.remove(); } catch {} };
|
||||
|
||||
s.onload = () => { cleanup(); resolve({ ok: true }); };
|
||||
s.onerror = () => { cleanup(); resolve({ ok: false }); };
|
||||
|
||||
// codeql[js/xss-through-dom]: the origin is validated (http/https, no creds),
|
||||
// and the path is fixed to ONLYOFFICE api.js via URL(), so this is safe.
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
|
||||
return new Promise(resolve => {
|
||||
const base = getTrustedDocsOrigin(docsOrigin);
|
||||
if (!base) { resolve({ ok: false }); return; }
|
||||
|
||||
const f = document.createElement('iframe');
|
||||
f.id = 'ooProbeFrame';
|
||||
f.src = base; // only the sanitized origin
|
||||
f.style.display = 'none';
|
||||
|
||||
// Optional: keep it extra constrained while probing.
|
||||
// If your DS needs broader privileges, you can drop sandbox.
|
||||
// f.sandbox = 'allow-same-origin allow-scripts';
|
||||
|
||||
const cleanup = () => { try { f.remove(); } catch {} };
|
||||
const t = setTimeout(() => { cleanup(); resolve({ ok: false, timeout: true }); }, timeoutMs);
|
||||
|
||||
f.onload = () => { clearTimeout(t); cleanup(); resolve({ ok: true }); };
|
||||
f.onerror = () => { clearTimeout(t); cleanup(); resolve({ ok: false }); };
|
||||
|
||||
// codeql[js/xss-through-dom]: src is constrained to a validated http/https origin.
|
||||
document.body.appendChild(f);
|
||||
});
|
||||
}
|
||||
// Main test runner
|
||||
async function runOnlyOfficeTests() {
|
||||
const spinner = document.getElementById('ooTestSpinner');
|
||||
const out = document.getElementById('ooTestResults');
|
||||
const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim();
|
||||
|
||||
spinner.style.display = 'inline';
|
||||
ooClear(out);
|
||||
|
||||
// 1) FileRise status
|
||||
let statusOk = false, statusJson = null;
|
||||
try {
|
||||
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
|
||||
statusJson = await r.json().catch(() => ({}));
|
||||
if (r.ok) {
|
||||
if (statusJson.enabled) {
|
||||
out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready'));
|
||||
statusOk = true;
|
||||
} else {
|
||||
// Disabled usually means missing secret or origin; we’ll dig deeper below.
|
||||
out.appendChild(ooRow('FileRise status', 'warn', 'Disabled — check JWT Secret and Document Server Origin'));
|
||||
}
|
||||
} else {
|
||||
out.appendChild(ooRow('FileRise status', 'fail', `HTTP ${r.status}`));
|
||||
}
|
||||
} catch (e) {
|
||||
out.appendChild(ooRow('FileRise status', 'fail', (e && e.message) || 'Network error'));
|
||||
}
|
||||
|
||||
// 2) Secret presence (fresh read)
|
||||
try {
|
||||
const cfg = await fetch('/api/admin/getConfig.php', { credentials: 'include', cache: 'no-store' }).then(r => r.json());
|
||||
const hasSecret = !!(cfg.onlyoffice && cfg.onlyoffice.hasJwtSecret);
|
||||
out.appendChild(ooRow('JWT secret saved', hasSecret ? 'ok' : 'fail', hasSecret ? 'Present' : 'Missing'));
|
||||
} catch {
|
||||
out.appendChild(ooRow('JWT secret saved', 'warn', 'Could not verify'));
|
||||
}
|
||||
|
||||
// 3) Callback reachable (basic ping)
|
||||
try {
|
||||
const r = await fetch('/api/onlyoffice/callback.php?ping=1', { credentials: 'include', cache: 'no-store' });
|
||||
if (r.ok) out.appendChild(ooRow('Callback endpoint', 'ok', 'Reachable'));
|
||||
else out.appendChild(ooRow('Callback endpoint', 'fail', `HTTP ${r.status}`));
|
||||
} catch {
|
||||
out.appendChild(ooRow('Callback endpoint', 'fail', 'Network error'));
|
||||
}
|
||||
|
||||
// Early sanity on origin
|
||||
if (!/^https?:\/\//i.test(docsOrigin)) {
|
||||
out.appendChild(ooRow('Document Server Origin', 'fail', 'Enter a valid http(s) origin (e.g., https://docs.example.com)'));
|
||||
spinner.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// 4a) Can browser load api.js (also surfaces CSP script-src issues)
|
||||
const sRes = await ooProbeScript(docsOrigin);
|
||||
out.appendChild(ooRow('Load api.js', sRes.ok ? 'ok' : 'fail', sRes.ok ? 'Loaded' : 'Blocked (check CSP script-src and origin)'));
|
||||
|
||||
// 4b) Can browser embed DS in an iframe (CSP frame-src)
|
||||
const fRes = await ooProbeFrame(docsOrigin);
|
||||
out.appendChild(ooRow('Embed DS iframe', fRes.ok ? 'ok' : 'fail', fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)'));
|
||||
|
||||
// Optional tip if we see common red flags
|
||||
if (!statusOk || !sRes.ok || !fRes.ok) {
|
||||
const tip = document.createElement('li');
|
||||
tip.style.marginTop = '8px';
|
||||
tip.innerHTML = "💡 <em>Tip:</em> Use the CSP helper above to include your Document Server in <code>script-src</code>, <code>connect-src</code>, and <code>frame-src</code>.";
|
||||
out.appendChild(tip);
|
||||
}
|
||||
|
||||
spinner.style.display = 'none';
|
||||
}
|
||||
|
||||
// Wire the button
|
||||
document.getElementById('ooTestBtn')?.addEventListener('click', runOnlyOfficeTests);
|
||||
|
||||
|
||||
|
||||
// Append CSP help box
|
||||
// --- CSP help box (replace your whole block with this) ---
|
||||
const ooSec = document.getElementById("onlyofficeContent");
|
||||
const cspHelp = document.createElement("div");
|
||||
cspHelp.className = "alert alert-info";
|
||||
cspHelp.style.marginTop = "12px";
|
||||
cspHelp.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||||
<strong>Content-Security-Policy help</strong>
|
||||
<button type="button" id="copyOoCsp" class="btn btn-sm btn-outline-secondary">Copy</button>
|
||||
<button type="button" id="selectOoCsp" class="btn btn-sm btn-outline-secondary">Select</button>
|
||||
</div>
|
||||
<div class="form-text" style="margin-bottom:8px;">
|
||||
Add/replace this line in <code>public/.htaccess</code> (Apache). It allows loading ONLYOFFICE's <code>api.js</code>,
|
||||
embedding the editor iframe, and letting the script make XHR to your Document Server.
|
||||
</div>
|
||||
<pre id="ooCspSnippet" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7;"></pre>
|
||||
<div class="form-text" style="margin-top:8px;">
|
||||
If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead.
|
||||
Also note: if your site is <code>https://</code>, your ONLYOFFICE server must be <code>https://</code> too,
|
||||
otherwise the browser will block it as mixed content.
|
||||
</div>
|
||||
<details style="margin-top:8px;">
|
||||
<summary>Nginx equivalent</summary>
|
||||
<pre id="ooCspSnippetNginx" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7; margin-top:6px;"></pre>
|
||||
</details>
|
||||
`;
|
||||
ooSec.appendChild(cspHelp);
|
||||
|
||||
const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=";
|
||||
|
||||
function buildCspApache(originRaw) {
|
||||
const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, '');
|
||||
const api = `${o}/web-apps/apps/api/documents/api.js`;
|
||||
return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
|
||||
}
|
||||
function buildCspNginx(originRaw) {
|
||||
const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, '');
|
||||
const api = `${o}/web-apps/apps/api/documents/api.js`;
|
||||
return `add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}" always;`;
|
||||
}
|
||||
|
||||
const ooDocsInput = document.getElementById("ooDocsOrigin");
|
||||
const cspPre = document.getElementById("ooCspSnippet");
|
||||
const cspPreNgx = document.getElementById("ooCspSnippetNginx");
|
||||
|
||||
function refreshCsp() {
|
||||
const raw = (ooDocsInput?.value || "").trim();
|
||||
const base = getTrustedDocsOrigin(raw) || raw; // fall back to raw so users see their input
|
||||
cspPre.textContent = buildCspApache(base);
|
||||
cspPreNgx.textContent = buildCspNginx(base);
|
||||
}
|
||||
ooDocsInput?.addEventListener("input", refreshCsp);
|
||||
refreshCsp();
|
||||
|
||||
// ---- Copy helpers (with robust fallback) ----
|
||||
async function copyToClipboard(text) {
|
||||
// Best path: async clipboard API in a secure context (https/localhost)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try { await navigator.clipboard.writeText(text); return true; }
|
||||
catch (_) { /* fall through */ }
|
||||
}
|
||||
// Fallback for http or blocked clipboard: hidden textarea + execCommand
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy'); // deprecated but still widely supported
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function selectElementContents(el) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
document.getElementById("copyOoCsp")?.addEventListener("click", async () => {
|
||||
const txt = (cspPre.textContent || "").trim();
|
||||
const ok = await copyToClipboard(txt);
|
||||
if (ok) {
|
||||
showToast("CSP line copied.");
|
||||
} else {
|
||||
// Auto-select so the user can Ctrl/Cmd+C as a last resort
|
||||
try { selectElementContents(cspPre); } catch { }
|
||||
const reason = window.isSecureContext ? "" : " (page is not HTTPS or localhost)";
|
||||
showToast("Copy failed" + reason + ". Press Ctrl/Cmd+C to copy.");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("selectOoCsp")?.addEventListener("click", () => {
|
||||
try { selectElementContents(cspPre); showToast("Selected — press Ctrl/Cmd+C"); }
|
||||
catch { /* ignore */ }
|
||||
});
|
||||
|
||||
document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled);
|
||||
document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : "";
|
||||
|
||||
const hasId = !!(config.oidc && config.oidc.hasClientId);
|
||||
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
|
||||
|
||||
@@ -696,10 +1003,24 @@ export function openAdminPanel() {
|
||||
document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User";
|
||||
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
|
||||
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
|
||||
// remember lock for handleSave
|
||||
window.__OO_LOCKED = !!(config.onlyoffice && config.onlyoffice.lockedByPhp);
|
||||
if (window.__OO_LOCKED) {
|
||||
const sec = document.getElementById("onlyofficeContent");
|
||||
sec.querySelectorAll("input,button").forEach(el => el.disabled = true);
|
||||
const note = document.createElement("div");
|
||||
note.className = "form-text";
|
||||
note.style.marginTop = "6px";
|
||||
note.textContent = "Managed by config.php — edit ONLYOFFICE_* constants there.";
|
||||
sec.appendChild(note);
|
||||
}
|
||||
captureInitialAdminConfig();
|
||||
|
||||
} else {
|
||||
mdl.style.display = "flex";
|
||||
const hasId = !!(config.oidc && config.oidc.hasClientId);
|
||||
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
|
||||
|
||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
||||
@@ -713,6 +1034,10 @@ export function openAdminPanel() {
|
||||
if (!hasId) idEl.value = window.currentOIDCConfig?.clientId || "";
|
||||
if (!hasSecret) secEl.value = window.currentOIDCConfig?.clientSecret || "";
|
||||
wireReplaceButtons(document.getElementById("oidcContent"));
|
||||
document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled);
|
||||
document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : "";
|
||||
const ooCont = document.getElementById("onlyofficeContent");
|
||||
if (ooCont) wireReplaceButtons(ooCont);
|
||||
document.getElementById("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || "";
|
||||
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || "";
|
||||
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || '';
|
||||
@@ -745,11 +1070,39 @@ 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");
|
||||
|
||||
payload.onlyoffice = {
|
||||
enabled: document.getElementById("ooEnabled").checked,
|
||||
docsOrigin: document.getElementById("ooDocsOrigin").value.trim()
|
||||
};
|
||||
|
||||
if (ooSecretEl?.dataset.replace === '1' && ooSecretEl.value.trim() !== '') {
|
||||
payload.onlyoffice.jwtSecret = ooSecretEl.value.trim();
|
||||
}
|
||||
|
||||
// ---- ONLYOFFICE payload ----
|
||||
if (!window.__OO_LOCKED) {
|
||||
const ooSecretVal = (document.getElementById("ooJwtSecret")?.value || "").trim();
|
||||
payload.onlyoffice = {
|
||||
enabled: document.getElementById("ooEnabled").checked,
|
||||
docsOrigin: document.getElementById("ooDocsOrigin").value.trim()
|
||||
};
|
||||
// If user typed a secret (non-empty), send it (server keeps it if non-empty)
|
||||
if (ooSecretVal !== "") {
|
||||
payload.onlyoffice.jwtSecret = ooSecretVal;
|
||||
}
|
||||
}
|
||||
|
||||
fetch('/api/admin/updateConfig.php', {
|
||||
|
||||
@@ -5,10 +5,24 @@ import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
|
||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
|
||||
import { initTagSearch } from './fileTags.js?v={{APP_QVER}}';
|
||||
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { initFileActions, openUploadModal } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { initUpload } from './upload.js?v={{APP_QVER}}';
|
||||
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
|
||||
|
||||
window.__pendingDropData = null;
|
||||
|
||||
function waitFor(selector, timeout = 1200) {
|
||||
return new Promise(resolve => {
|
||||
const t0 = performance.now();
|
||||
(function tick() {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) return resolve(el);
|
||||
if (performance.now() - t0 >= timeout) return resolve(null);
|
||||
requestAnimationFrame(tick);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
// Keep a bound handle to the native fetch so wrappers elsewhere never recurse
|
||||
const _nativeFetch = window.fetch.bind(window);
|
||||
|
||||
@@ -84,25 +98,53 @@ export function initializeApp() {
|
||||
// Enable tag search UI; initial file list load is controlled elsewhere
|
||||
initTagSearch();
|
||||
|
||||
|
||||
// Hook DnD relay from fileList area into upload area
|
||||
const fileListArea = document.getElementById('fileListContainer');
|
||||
const uploadArea = document.getElementById('uploadDropArea');
|
||||
if (fileListArea && uploadArea) {
|
||||
|
||||
if (fileListArea) {
|
||||
let hoverTimer = null;
|
||||
|
||||
fileListArea.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
fileListArea.classList.add('drop-hover');
|
||||
// (optional) auto-open after brief hover so users see the drop target
|
||||
if (!hoverTimer) {
|
||||
hoverTimer = setTimeout(() => {
|
||||
if (typeof window.openUploadModal === 'function') window.openUploadModal();
|
||||
}, 400);
|
||||
}
|
||||
});
|
||||
|
||||
fileListArea.addEventListener('dragleave', () => {
|
||||
fileListArea.classList.remove('drop-hover');
|
||||
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||
});
|
||||
fileListArea.addEventListener('drop', e => {
|
||||
|
||||
fileListArea.addEventListener('drop', async e => {
|
||||
e.preventDefault();
|
||||
fileListArea.classList.remove('drop-hover');
|
||||
uploadArea.dispatchEvent(new DragEvent('drop', {
|
||||
dataTransfer: e.dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||
|
||||
// 1) open the same modal that the Create menu uses
|
||||
openUploadModal();
|
||||
// 2) wait until the upload area exists *in the modal*, then relay the drop
|
||||
// Prefer a scoped selector first to avoid duplicate IDs.
|
||||
const uploadArea =
|
||||
(await waitFor('#uploadModal #uploadDropArea')) ||
|
||||
(await waitFor('#uploadDropArea'));
|
||||
if (!uploadArea) return;
|
||||
|
||||
try {
|
||||
// Many browsers make dataTransfer read-only; we try the direct attach first
|
||||
const relay = new DragEvent('drop', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(relay, 'dataTransfer', { value: e.dataTransfer });
|
||||
uploadArea.dispatchEvent(relay);
|
||||
} catch {
|
||||
// Fallback: stash DataTransfer and fire a plain event; handler will read the stash
|
||||
window.__pendingDropData = e.dataTransfer || null;
|
||||
uploadArea.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
// Promote any preloaded styles to real stylesheets without inline handlers (CSP-safe)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Promote any preloaded core CSS
|
||||
document.querySelectorAll('link[rel="preload"][as="style"][href]').forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
if ([...document.querySelectorAll('link[rel="stylesheet"]')]
|
||||
.some(s => s.getAttribute('href') === href)) return;
|
||||
const sheet = document.createElement('link');
|
||||
sheet.rel = 'stylesheet';
|
||||
sheet.href = href;
|
||||
document.head.appendChild(sheet);
|
||||
});
|
||||
// /public/js/defer-css.js
|
||||
// Promote preloaded styles to real stylesheets (CSP-safe) and expose a load promise.
|
||||
(function () {
|
||||
if (window.__CSS_PROMISE__) return;
|
||||
|
||||
var loads = [];
|
||||
|
||||
// Optionally load non-critical icon/extra font CSS after first paint:
|
||||
const extra = document.createElement('link');
|
||||
extra.rel = 'stylesheet';
|
||||
extra.href = '/css/vendor/material-icons.css?v={{APP_QVER}}';
|
||||
document.head.appendChild(extra);
|
||||
});
|
||||
// Promote <link rel="preload" as="style"> IN-PLACE
|
||||
var preloads = document.querySelectorAll('link[rel="preload"][as="style"]');
|
||||
for (var i = 0; i < preloads.length; i++) {
|
||||
var l = preloads[i];
|
||||
// resolve when it finishes loading as a stylesheet
|
||||
loads.push(new Promise(function (res) { l.addEventListener('load', res, { once: true }); }));
|
||||
l.rel = 'stylesheet';
|
||||
if (!l.media || l.media === 'print') l.media = 'all'; // be explicit
|
||||
l.removeAttribute('as'); // keep some engines happy about "used" preload
|
||||
}
|
||||
|
||||
// Also wait for any existing <link rel="stylesheet"> that haven't finished yet
|
||||
var styles = document.querySelectorAll('link[rel="stylesheet"]');
|
||||
for (var j = 0; j < styles.length; j++) {
|
||||
var s = styles[j];
|
||||
if (s.sheet) continue; // already applied
|
||||
loads.push(new Promise(function (res) { s.addEventListener('load', res, { once: true }); }));
|
||||
}
|
||||
|
||||
// Safari quirk: nudge layout so promoted sheets apply immediately
|
||||
void document.documentElement.offsetHeight;
|
||||
|
||||
window.__CSS_PROMISE__ = Promise.all(loads);
|
||||
})();
|
||||
@@ -2,6 +2,7 @@
|
||||
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
export function handleDeleteSelected(e) {
|
||||
@@ -12,7 +13,6 @@ export function handleDeleteSelected(e) {
|
||||
showToast("no_files_selected");
|
||||
return;
|
||||
}
|
||||
|
||||
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
|
||||
const count = window.filesToDelete.length;
|
||||
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
|
||||
@@ -20,6 +20,52 @@ export function handleDeleteSelected(e) {
|
||||
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
||||
}
|
||||
|
||||
|
||||
// --- Upload modal "portal" support ---
|
||||
let _uploadCardSentinel = null;
|
||||
|
||||
export function openUploadModal() {
|
||||
const modal = document.getElementById('uploadModal');
|
||||
const body = document.getElementById('uploadModalBody');
|
||||
const card = document.getElementById('uploadCard'); // <-- your existing card
|
||||
window.openUploadModal = openUploadModal;
|
||||
window.__pendingDropData = null;
|
||||
if (!modal || !body || !card) {
|
||||
console.warn('Upload modal or upload card not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a hidden sentinel so we can put the card back in place later
|
||||
if (!_uploadCardSentinel) {
|
||||
_uploadCardSentinel = document.createElement('div');
|
||||
_uploadCardSentinel.id = 'uploadCardSentinel';
|
||||
_uploadCardSentinel.style.display = 'none';
|
||||
card.parentNode.insertBefore(_uploadCardSentinel, card);
|
||||
}
|
||||
|
||||
// Move the actual card node into the modal (keeps all existing listeners)
|
||||
body.appendChild(card);
|
||||
|
||||
// Show modal
|
||||
modal.style.display = 'block';
|
||||
|
||||
// Focus the chooser for quick keyboard flow
|
||||
setTimeout(() => {
|
||||
const chooseBtn = document.getElementById('customChooseBtn');
|
||||
if (chooseBtn) chooseBtn.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
export function closeUploadModal() {
|
||||
const modal = document.getElementById('uploadModal');
|
||||
const card = document.getElementById('uploadCard');
|
||||
|
||||
if (_uploadCardSentinel && _uploadCardSentinel.parentNode && card) {
|
||||
_uploadCardSentinel.parentNode.insertBefore(card, _uploadCardSentinel);
|
||||
}
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cancelDelete = document.getElementById("cancelDeleteFiles");
|
||||
if (cancelDelete) {
|
||||
@@ -47,6 +93,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (data.success) {
|
||||
showToast("Selected files deleted successfully!");
|
||||
loadFileList(window.currentFolder);
|
||||
refreshFolderIcon(window.currentFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not delete files"));
|
||||
}
|
||||
@@ -119,7 +166,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`
|
||||
@@ -129,6 +176,7 @@ export async function handleCreateFile(e) {
|
||||
if (!js.success) throw new Error(js.error);
|
||||
showToast(t('file_created'));
|
||||
loadFileList(folder);
|
||||
refreshFolderIcon(folder);
|
||||
} catch (err) {
|
||||
showToast(err.message || t('error_creating_file'));
|
||||
} finally {
|
||||
@@ -139,7 +187,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 +313,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';
|
||||
@@ -300,12 +348,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
showToast(t('file_created_successfully'));
|
||||
loadFileList(window.currentFolder);
|
||||
refreshFolderIcon(folder);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast(err.message || t('error_creating_file'));
|
||||
}
|
||||
});
|
||||
attachEnterKeyListener('createFileModal','confirmCreateFile');
|
||||
attachEnterKeyListener('createFileModal', 'confirmCreateFile');
|
||||
}
|
||||
|
||||
// 1) Cancel button hides the name modal
|
||||
@@ -321,63 +370,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 = [];
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -509,6 +682,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (data.success) {
|
||||
showToast("Selected files copied successfully!", 5000);
|
||||
loadFileList(window.currentFolder);
|
||||
refreshFolderIcon(targetFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not copy files"), 5000);
|
||||
}
|
||||
@@ -561,6 +735,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (data.success) {
|
||||
showToast("Selected files moved successfully!");
|
||||
loadFileList(window.currentFolder);
|
||||
refreshFolderIcon(targetFolder);
|
||||
refreshFolderIcon(window.currentFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not move files"));
|
||||
}
|
||||
@@ -694,10 +870,11 @@ 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');
|
||||
const uploadOpt = document.getElementById('uploadOption'); // NEW
|
||||
|
||||
// Toggle dropdown on click
|
||||
btn.addEventListener('click', (e) => {
|
||||
@@ -722,6 +899,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('click', () => {
|
||||
menu.style.display = 'none';
|
||||
});
|
||||
if (uploadOpt) {
|
||||
uploadOpt.addEventListener('click', () => {
|
||||
if (menu) menu.style.display = 'none';
|
||||
openUploadModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Close buttons / backdrop
|
||||
const upModal = document.getElementById('uploadModal');
|
||||
const closeX = document.getElementById('closeUploadModal');
|
||||
|
||||
if (closeX) closeX.addEventListener('click', closeUploadModal);
|
||||
|
||||
// click outside content to close
|
||||
if (upModal) {
|
||||
upModal.addEventListener('click', (e) => {
|
||||
if (e.target === upModal) closeUploadModal();
|
||||
});
|
||||
}
|
||||
|
||||
// ESC to close
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && upModal && upModal.style.display === 'block') {
|
||||
closeUploadModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
window.renameFile = renameFile;
|
||||
@@ -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).');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
||||
|
||||
// thresholds for editor behavior
|
||||
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
|
||||
@@ -14,7 +15,7 @@ const CM_BASE = "/vendor/codemirror/5.65.5/";
|
||||
const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`;
|
||||
|
||||
const CORE = {
|
||||
js: coreUrl("codemirror.min.js"),
|
||||
js: coreUrl("codemirror.min.js"),
|
||||
css: coreUrl("codemirror.min.css"),
|
||||
themeCss: coreUrl("theme/material-darker.min.css"),
|
||||
};
|
||||
@@ -22,30 +23,30 @@ const CORE = {
|
||||
// Which mode file to load for a given name/mime
|
||||
const MODE_URL = {
|
||||
// core/common
|
||||
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
|
||||
"css": "mode/css/css.min.js?v={{APP_QVER}}",
|
||||
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
|
||||
"css": "mode/css/css.min.js?v={{APP_QVER}}",
|
||||
"javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}",
|
||||
|
||||
// meta / combos
|
||||
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
|
||||
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
|
||||
"application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}",
|
||||
|
||||
// docs / data
|
||||
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
|
||||
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
|
||||
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
|
||||
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
|
||||
"properties": "mode/properties/properties.min.js?v={{APP_QVER}}",
|
||||
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
|
||||
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
|
||||
|
||||
// shells
|
||||
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
|
||||
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
|
||||
|
||||
// languages
|
||||
"python": "mode/python/python.min.js?v={{APP_QVER}}",
|
||||
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
|
||||
"python": "mode/python/python.min.js?v={{APP_QVER}}",
|
||||
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
|
||||
};
|
||||
|
||||
// Mode dependency graph
|
||||
@@ -64,18 +65,52 @@ function normalizeModeName(modeOption) {
|
||||
return name;
|
||||
}
|
||||
|
||||
// ---- ONLYOFFICE integration -----------------------------------------------
|
||||
|
||||
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
|
||||
|
||||
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
|
||||
let __ooCaps = { enabled: false, exts: new Set(), fetched: false, docsOrigin: null };
|
||||
|
||||
async function fetchOnlyOfficeCapsOnce() {
|
||||
if (__ooCaps.fetched) return __ooCaps;
|
||||
try {
|
||||
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
__ooCaps.enabled = !!j.enabled;
|
||||
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
|
||||
__ooCaps.docsOrigin = j.docsOrigin || null; // harmless if server doesn't send it
|
||||
}
|
||||
} catch { /* ignore; keep defaults */ }
|
||||
__ooCaps.fetched = true;
|
||||
return __ooCaps;
|
||||
}
|
||||
|
||||
async function shouldUseOnlyOffice(fileName) {
|
||||
const { enabled, exts } = await fetchOnlyOfficeCapsOnce();
|
||||
return enabled && exts.has(getExt(fileName));
|
||||
}
|
||||
|
||||
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
|
||||
|
||||
// ---- 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);
|
||||
});
|
||||
}
|
||||
@@ -108,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);
|
||||
}
|
||||
@@ -133,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() : "";
|
||||
@@ -195,29 +519,48 @@ function observeModalResize(modal) {
|
||||
}
|
||||
export { observeModalResize };
|
||||
|
||||
export function editFile(fileName, folder) {
|
||||
export async function editFile(fileName, folder) {
|
||||
// destroy any previous editor
|
||||
let existingEditor = document.getElementById("editorContainer");
|
||||
if (existingEditor) existingEditor.remove();
|
||||
|
||||
const folderUsed = folder || window.currentFolder || "root";
|
||||
const folderPath = folderUsed === "root"
|
||||
? "uploads/"
|
||||
: "uploads/" + folderUsed.split("/").map(encodeURIComponent).join("/") + "/";
|
||||
const fileUrl = folderPath + encodeURIComponent(fileName) + "?t=" + new Date().getTime();
|
||||
const fileUrl = buildPreviewUrl(folderUsed, fileName);
|
||||
|
||||
fetch(fileUrl, { method: "HEAD" })
|
||||
.then(response => {
|
||||
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
|
||||
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||
if (await shouldUseOnlyOffice(fileName)) {
|
||||
await openOnlyOffice(fileName, folderUsed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET.
|
||||
async function probeSize(url) {
|
||||
try {
|
||||
const h = await fetch(url, { method: "HEAD", credentials: "include" });
|
||||
const len = h.headers.get("content-length") ?? h.headers.get("Content-Length");
|
||||
if (len && !Number.isNaN(parseInt(len, 10))) return parseInt(len, 10);
|
||||
} catch { }
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Range: "bytes=0-0" },
|
||||
credentials: "include"
|
||||
});
|
||||
// Content-Range: bytes 0-0/12345
|
||||
const cr = r.headers.get("content-range") ?? r.headers.get("Content-Range");
|
||||
const m = cr && cr.match(/\/(\d+)\s*$/);
|
||||
if (m) return parseInt(m[1], 10);
|
||||
} catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
probeSize(fileUrl)
|
||||
.then(sizeBytes => {
|
||||
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
|
||||
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
||||
throw new Error("File too large.");
|
||||
}
|
||||
return response;
|
||||
return fetch(fileUrl, { credentials: "include" });
|
||||
})
|
||||
.then(() => fetch(fileUrl))
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error("HTTP error! Status: " + response.status);
|
||||
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
|
||||
@@ -269,8 +612,8 @@ export function editFile(fileName, folder) {
|
||||
// Keep buttons responsive even before editor exists
|
||||
const decBtn = document.getElementById("decreaseFont");
|
||||
const incBtn = document.getElementById("increaseFont");
|
||||
decBtn.addEventListener("click", () => {});
|
||||
incBtn.addEventListener("click", () => {});
|
||||
decBtn.addEventListener("click", () => { });
|
||||
incBtn.addEventListener("click", () => { });
|
||||
|
||||
// Theme + mode selection
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
@@ -301,38 +644,36 @@ export 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
|
||||
@@ -345,7 +686,7 @@ export 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);
|
||||
@@ -355,12 +696,10 @@ export 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 => {
|
||||
|
||||
@@ -34,6 +34,25 @@ import {
|
||||
|
||||
export let fileData = [];
|
||||
export let sortOrder = { column: "uploaded", ascending: true };
|
||||
|
||||
|
||||
|
||||
// onnlyoffice
|
||||
let OO_ENABLED = false;
|
||||
let OO_EXTS = new Set();
|
||||
|
||||
export async function initOnlyOfficeCaps() {
|
||||
try {
|
||||
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
|
||||
if (!r.ok) throw 0;
|
||||
const j = await r.json();
|
||||
OO_ENABLED = !!j.enabled;
|
||||
OO_EXTS = new Set(Array.isArray(j.exts) ? j.exts : []);
|
||||
} catch {
|
||||
OO_ENABLED = false;
|
||||
OO_EXTS = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
// Hide "Edit" for files >10 MiB
|
||||
const MAX_EDIT_BYTES = 10 * 1024 * 1024;
|
||||
@@ -138,7 +157,121 @@ function wireSelectAll(fileListContent) {
|
||||
}
|
||||
return body ?? {};
|
||||
}
|
||||
|
||||
// ---- Viewed badges (table + gallery) ----
|
||||
// ---------- Badge factory (center text vertically) ----------
|
||||
function makeBadge(state) {
|
||||
if (!state) return null;
|
||||
const el = document.createElement('span');
|
||||
el.className = 'status-badge';
|
||||
el.style.cssText = [
|
||||
'display:inline-flex',
|
||||
'align-items:center',
|
||||
'justify-content:center',
|
||||
'vertical-align:middle',
|
||||
'margin-left:6px',
|
||||
'padding:2px 8px',
|
||||
'min-height:18px',
|
||||
'line-height:1',
|
||||
'border-radius:999px',
|
||||
'font-size:.78em',
|
||||
'border:1px solid rgba(0,0,0,.2)',
|
||||
'background:rgba(0,0,0,.06)'
|
||||
].join(';');
|
||||
|
||||
if (state.completed) {
|
||||
el.classList.add('watched');
|
||||
el.textContent = (t('watched') || t('viewed') || 'Watched');
|
||||
el.style.borderColor = 'rgba(34,197,94,.45)';
|
||||
el.style.background = 'rgba(34,197,94,.15)';
|
||||
el.style.color = '#22c55e';
|
||||
return el;
|
||||
}
|
||||
|
||||
if (Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
el.classList.add('progress');
|
||||
el.textContent = `${pct}%`;
|
||||
el.style.borderColor = 'rgba(234,88,12,.55)';
|
||||
el.style.background = 'rgba(234,88,12,.18)';
|
||||
el.style.color = '#ea580c';
|
||||
return el;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- Public: set/clear badges for one file (table + gallery) ----------
|
||||
function applyBadgeToDom(name, state) {
|
||||
const safe = CSS.escape(name);
|
||||
|
||||
// Table
|
||||
document.querySelectorAll(`tr[data-file-name="${safe}"] .name-cell, tr[data-file-name="${safe}"] .file-name-cell`)
|
||||
.forEach(cell => {
|
||||
cell.querySelector('.status-badge')?.remove();
|
||||
const b = makeBadge(state);
|
||||
if (b) cell.appendChild(b);
|
||||
});
|
||||
|
||||
// Gallery
|
||||
document.querySelectorAll(`.gallery-card[data-file-name="${safe}"] .gallery-file-name`)
|
||||
.forEach(title => {
|
||||
title.querySelector('.status-badge')?.remove();
|
||||
const b = makeBadge(state);
|
||||
if (b) title.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
export function setFileWatchedBadge(name, watched = true) {
|
||||
applyBadgeToDom(name, watched ? { completed: true } : null);
|
||||
}
|
||||
|
||||
export function setFileProgressBadge(name, seconds, duration) {
|
||||
if (duration > 0 && seconds >= 0) {
|
||||
applyBadgeToDom(name, { seconds, duration, completed: seconds >= duration - 1 });
|
||||
} else {
|
||||
applyBadgeToDom(name, null);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshViewedBadges(folder) {
|
||||
let map = null;
|
||||
try {
|
||||
const res = await fetch(`/api/media/getViewedMap.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`, { credentials: 'include' });
|
||||
const j = await res.json();
|
||||
map = j?.map || null;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Clear any existing badges
|
||||
document.querySelectorAll(
|
||||
'#fileList tr[data-file-name] .file-name-cell .status-badge, ' +
|
||||
'#fileList tr[data-file-name] .name-cell .status-badge, ' +
|
||||
'.gallery-card[data-file-name] .gallery-file-name .status-badge'
|
||||
).forEach(n => n.remove());
|
||||
|
||||
if (!map) return;
|
||||
|
||||
// Table rows
|
||||
document.querySelectorAll('#fileList tr[data-file-name]').forEach(tr => {
|
||||
const name = tr.getAttribute('data-file-name');
|
||||
const state = map[name];
|
||||
if (!state) return;
|
||||
const cell = tr.querySelector('.name-cell, .file-name-cell');
|
||||
if (!cell) return;
|
||||
const badge = makeBadge(state);
|
||||
if (badge) cell.appendChild(badge);
|
||||
});
|
||||
|
||||
// Gallery cards
|
||||
document.querySelectorAll('.gallery-card[data-file-name]').forEach(card => {
|
||||
const name = card.getAttribute('data-file-name');
|
||||
const state = map[name];
|
||||
if (!state) return;
|
||||
const title = card.querySelector('.gallery-file-name');
|
||||
if (!title) return;
|
||||
const badge = makeBadge(state);
|
||||
if (badge) title.appendChild(badge);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes.
|
||||
*/
|
||||
@@ -338,6 +471,7 @@ function searchFiles(searchTerm) {
|
||||
window.updateRowHighlight = updateRowHighlight;
|
||||
|
||||
export async function loadFileList(folderParam) {
|
||||
await initOnlyOfficeCaps();
|
||||
const reqId = ++__fileListReqSeq; // latest call wins
|
||||
const folder = folderParam || "root";
|
||||
const fileListContainer = document.getElementById("fileList");
|
||||
@@ -528,6 +662,7 @@ function searchFiles(searchTerm) {
|
||||
}
|
||||
updateFileActionButtons();
|
||||
fileListContainer.style.visibility = "visible";
|
||||
|
||||
|
||||
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
|
||||
try {
|
||||
@@ -692,9 +827,14 @@ function searchFiles(searchTerm) {
|
||||
if (totalFiles > 0) {
|
||||
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
|
||||
// Build row with a neutral base, then correct the links/preview below.
|
||||
let rowHTML = buildFileTableRow(file, fakeBase);
|
||||
// Give the row an ID so we can patch attributes safely
|
||||
rowHTML = rowHTML.replace("<tr", `<tr id="file-row-${encodeURIComponent(file.name)}-${startIndex + idx}"`);
|
||||
const idSafe = encodeURIComponent(file.name) + "-" + (startIndex + idx);
|
||||
let rowHTML = buildFileTableRow(file, fakeBase);
|
||||
|
||||
// add row id + data-file-name, and ensure the name cell also has "name-cell"
|
||||
rowHTML = rowHTML
|
||||
.replace("<tr", `<tr id="file-row-${idSafe}" data-file-name="${escapeHTML(file.name)}"`)
|
||||
.replace('class="file-name-cell"', 'class="file-name-cell name-cell"');
|
||||
|
||||
let tagBadgesHTML = "";
|
||||
if (file.tags && file.tags.length > 0) {
|
||||
@@ -704,9 +844,13 @@ function searchFiles(searchTerm) {
|
||||
});
|
||||
tagBadgesHTML += "</div>";
|
||||
}
|
||||
rowsHTML += rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
|
||||
return p1 + p2 + tagBadgesHTML + p3;
|
||||
});
|
||||
rowsHTML += rowHTML.replace(
|
||||
/(<td\s+class="[^"]*\bfile-name-cell\b[^"]*">)([\s\S]*?)(<\/td>)/,
|
||||
(m, open, inner, close) => {
|
||||
// keep the original filename content, then add your tag badges, then close
|
||||
return `${open}<span class="filename-text">${inner}</span>${tagBadgesHTML}${close}`;
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`;
|
||||
@@ -884,6 +1028,7 @@ function searchFiles(searchTerm) {
|
||||
});
|
||||
});
|
||||
updateFileActionButtons();
|
||||
|
||||
document.querySelectorAll("#fileList tbody tr").forEach(row => {
|
||||
row.setAttribute("draggable", "true");
|
||||
import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
|
||||
@@ -894,6 +1039,7 @@ function searchFiles(searchTerm) {
|
||||
btn.addEventListener("click", e => e.stopPropagation());
|
||||
});
|
||||
bindFileListContextMenu();
|
||||
refreshViewedBadges(folder).catch(() => {});
|
||||
}
|
||||
|
||||
// A helper to compute the max image height based on the current column count.
|
||||
@@ -1020,6 +1166,7 @@ function searchFiles(searchTerm) {
|
||||
// card with checkbox, preview, info, buttons
|
||||
galleryHTML += `
|
||||
<div class="gallery-card"
|
||||
data-file-name="${escapeHTML(file.name)}"
|
||||
style="position:relative; border:1px solid #ccc; padding:5px; text-align:center;">
|
||||
<input type="checkbox"
|
||||
class="file-checkbox"
|
||||
@@ -1216,7 +1363,7 @@ function searchFiles(searchTerm) {
|
||||
if (window.viewMode === "gallery") renderGalleryView(folder);
|
||||
else renderFileTable(folder);
|
||||
};
|
||||
|
||||
refreshViewedBadges(folder).catch(() => {});
|
||||
updateFileActionButtons();
|
||||
createViewToggleButton();
|
||||
}
|
||||
@@ -1328,46 +1475,34 @@ function searchFiles(searchTerm) {
|
||||
if (!fileName || typeof fileName !== "string") return false;
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
if (dot < 0) return false;
|
||||
|
||||
const ext = fileName.slice(dot + 1).toLowerCase();
|
||||
|
||||
const allowedExtensions = [
|
||||
"txt", "text", "md", "markdown", "rst",
|
||||
"html", "htm", "xhtml", "shtml",
|
||||
"css", "scss", "sass", "less",
|
||||
"js", "mjs", "cjs", "jsx",
|
||||
"ts", "tsx",
|
||||
"json", "jsonc", "ndjson",
|
||||
"yml", "yaml", "toml", "xml", "plist",
|
||||
"ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc",
|
||||
"env", "dotenv",
|
||||
"csv", "tsv", "tab",
|
||||
// Your CodeMirror text-based types
|
||||
const textEditExts = new Set([
|
||||
"txt","text","md","markdown","rst",
|
||||
"html","htm","xhtml","shtml",
|
||||
"css","scss","sass","less",
|
||||
"js","mjs","cjs","jsx",
|
||||
"ts","tsx",
|
||||
"json","jsonc","ndjson",
|
||||
"yml","yaml","toml","xml","plist",
|
||||
"ini","conf","config","cfg","cnf","properties","props","rc",
|
||||
"env","dotenv",
|
||||
"csv","tsv","tab",
|
||||
"log",
|
||||
"sh", "bash", "zsh", "ksh", "fish",
|
||||
"bat", "cmd",
|
||||
"ps1", "psm1", "psd1",
|
||||
"py", "pyw",
|
||||
"rb",
|
||||
"pl", "pm",
|
||||
"go",
|
||||
"rs",
|
||||
"java",
|
||||
"kt", "kts",
|
||||
"scala", "sc",
|
||||
"groovy", "gradle",
|
||||
"c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx",
|
||||
"m", "mm",
|
||||
"swift",
|
||||
"cs", "fs", "fsx",
|
||||
"dart",
|
||||
"lua",
|
||||
"r", "rmd",
|
||||
"sql",
|
||||
"vue", "svelte",
|
||||
"twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade"
|
||||
];
|
||||
"sh","bash","zsh","ksh","fish",
|
||||
"bat","cmd",
|
||||
"ps1","psm1","psd1",
|
||||
"py","pyw","rb","pl","pm","go","rs","java","kt","kts",
|
||||
"scala","sc","groovy","gradle",
|
||||
"c","h","cpp","cxx","cc","hpp","hh","hxx",
|
||||
"m","mm","swift","cs","fs","fsx","dart","lua","r","rmd",
|
||||
"sql","vue","svelte","twig","mustache","hbs","handlebars","ejs","pug","jade"
|
||||
]);
|
||||
|
||||
return allowedExtensions.includes(ext);
|
||||
if (textEditExts.has(ext)) return true; // CodeMirror
|
||||
if (OO_ENABLED && OO_EXTS.has(ext)) return true; // ONLYOFFICE types if enabled
|
||||
return false;
|
||||
}
|
||||
|
||||
// Expose global functions for pagination and preview.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// fileMenu.js
|
||||
import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { previewFile } from './filePreview.js?v={{APP_QVER}}';
|
||||
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
||||
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
|
||||
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
|
||||
@@ -39,11 +39,11 @@ export function showFileContextMenu(x, y, menuItems) {
|
||||
});
|
||||
menu.appendChild(menuItem);
|
||||
});
|
||||
|
||||
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
menu.style.display = "block";
|
||||
|
||||
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
if (menuRect.bottom > viewportHeight) {
|
||||
@@ -62,7 +62,7 @@ export function hideFileContextMenu() {
|
||||
|
||||
export function fileListContextMenuHandler(e) {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
let row = e.target.closest("tr");
|
||||
if (row) {
|
||||
const checkbox = row.querySelector(".file-checkbox");
|
||||
@@ -71,9 +71,9 @@ export function fileListContextMenuHandler(e) {
|
||||
updateRowHighlight(checkbox);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
|
||||
|
||||
|
||||
let menuItems = [
|
||||
{ label: t("create_file"), action: () => openCreateFileModal() },
|
||||
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
|
||||
@@ -81,14 +81,14 @@ export function fileListContextMenuHandler(e) {
|
||||
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
|
||||
{ label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } }
|
||||
];
|
||||
|
||||
|
||||
if (selected.some(name => name.toLowerCase().endsWith(".zip"))) {
|
||||
menuItems.push({
|
||||
label: t("extract_zip"),
|
||||
action: () => { handleExtractZipSelected(new Event("click")); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (selected.length > 1) {
|
||||
menuItems.push({
|
||||
label: t("tag_selected"),
|
||||
@@ -100,36 +100,33 @@ export function fileListContextMenuHandler(e) {
|
||||
}
|
||||
else if (selected.length === 1) {
|
||||
const file = fileData.find(f => f.name === selected[0]);
|
||||
|
||||
|
||||
menuItems.push({
|
||||
label: t("preview"),
|
||||
action: () => {
|
||||
const folder = window.currentFolder || "root";
|
||||
const folderPath = folder === "root"
|
||||
? "uploads/"
|
||||
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
|
||||
previewFile(folderPath + encodeURIComponent(file.name) + "?t=" + new Date().getTime(), file.name);
|
||||
previewFile(buildPreviewUrl(folder, file.name), file.name);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (canEditFile(file.name)) {
|
||||
menuItems.push({
|
||||
label: t("edit"),
|
||||
action: () => { editFile(selected[0], window.currentFolder); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
menuItems.push({
|
||||
label: t("rename"),
|
||||
action: () => { renameFile(selected[0], window.currentFolder); }
|
||||
});
|
||||
|
||||
|
||||
menuItems.push({
|
||||
label: t("tag_file"),
|
||||
action: () => { openTagModal(file); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
showFileContextMenu(e.clientX, e.clientY, menuItems);
|
||||
}
|
||||
|
||||
@@ -140,7 +137,7 @@ export function bindFileListContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", function(e) {
|
||||
document.addEventListener("click", function (e) {
|
||||
const menu = document.getElementById("fileContextMenu");
|
||||
if (menu && menu.style.display === "block") {
|
||||
hideFileContextMenu();
|
||||
@@ -148,9 +145,9 @@ document.addEventListener("click", function(e) {
|
||||
});
|
||||
|
||||
// Rebind context menu after file table render.
|
||||
(function() {
|
||||
(function () {
|
||||
const originalRenderFileTable = window.renderFileTable;
|
||||
window.renderFileTable = function(folder) {
|
||||
window.renderFileTable = function (folder) {
|
||||
originalRenderFileTable(folder);
|
||||
bindFileListContextMenu();
|
||||
};
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// filePreview.js
|
||||
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { fileData } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { fileData, setFileProgressBadge, setFileWatchedBadge } from './fileListView.js?v={{APP_QVER}}';
|
||||
|
||||
// Build a preview URL that always goes through the API layer (respects ACLs/UPLOAD_DIR)
|
||||
export function buildPreviewUrl(folder, name) {
|
||||
const f = (!folder || folder === '') ? 'root' : String(folder);
|
||||
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
|
||||
}
|
||||
|
||||
/* -------------------------------- Share modal (existing) -------------------------------- */
|
||||
export function openShareModal(file, folder) {
|
||||
// Remove any existing modal
|
||||
const existing = document.getElementById("shareModal");
|
||||
if (existing) existing.remove();
|
||||
|
||||
// Build the modal
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "shareModal";
|
||||
modal.classList.add("modal");
|
||||
@@ -45,18 +50,9 @@ export function openShareModal(file, folder) {
|
||||
</div>
|
||||
|
||||
<p style="margin-top:15px;">${t("password_optional")}</p>
|
||||
<input
|
||||
type="text"
|
||||
id="sharePassword"
|
||||
placeholder="${t("password_optional")}"
|
||||
style="width:100%;padding:5px;"
|
||||
/>
|
||||
<input type="text" id="sharePassword" placeholder="${t("password_optional")}" style="width:100%;padding:5px;"/>
|
||||
|
||||
<button
|
||||
id="generateShareLinkBtn"
|
||||
class="btn btn-primary"
|
||||
style="margin-top:15px;"
|
||||
>
|
||||
<button id="generateShareLinkBtn" class="btn btn-primary" style="margin-top:15px;">
|
||||
${t("generate_share_link")}
|
||||
</button>
|
||||
|
||||
@@ -73,48 +69,32 @@ export function openShareModal(file, folder) {
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = "block";
|
||||
|
||||
// Close handler
|
||||
document.getElementById("closeShareModal")
|
||||
.addEventListener("click", () => modal.remove());
|
||||
document.getElementById("closeShareModal").addEventListener("click", () => modal.remove());
|
||||
document.getElementById("shareExpiration").addEventListener("change", e => {
|
||||
const container = document.getElementById("customExpirationContainer");
|
||||
container.style.display = e.target.value === "custom" ? "block" : "none";
|
||||
});
|
||||
|
||||
// Show/hide custom-duration inputs
|
||||
document.getElementById("shareExpiration")
|
||||
.addEventListener("change", e => {
|
||||
const container = document.getElementById("customExpirationContainer");
|
||||
container.style.display = e.target.value === "custom" ? "block" : "none";
|
||||
});
|
||||
document.getElementById("generateShareLinkBtn").addEventListener("click", () => {
|
||||
const sel = document.getElementById("shareExpiration");
|
||||
let value, unit;
|
||||
|
||||
// Generate share link
|
||||
document.getElementById("generateShareLinkBtn")
|
||||
.addEventListener("click", () => {
|
||||
const sel = document.getElementById("shareExpiration");
|
||||
let value, unit;
|
||||
if (sel.value === "custom") {
|
||||
value = parseInt(document.getElementById("customExpirationValue").value, 10);
|
||||
unit = document.getElementById("customExpirationUnit").value;
|
||||
} else {
|
||||
value = parseInt(sel.value, 10);
|
||||
unit = "minutes";
|
||||
}
|
||||
|
||||
if (sel.value === "custom") {
|
||||
value = parseInt(document.getElementById("customExpirationValue").value, 10);
|
||||
unit = document.getElementById("customExpirationUnit").value;
|
||||
} else {
|
||||
value = parseInt(sel.value, 10);
|
||||
unit = "minutes";
|
||||
}
|
||||
const password = document.getElementById("sharePassword").value;
|
||||
|
||||
const password = document.getElementById("sharePassword").value;
|
||||
|
||||
fetch("/api/file/createShareLink.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder,
|
||||
file: file.name,
|
||||
expirationValue: value,
|
||||
expirationUnit: unit,
|
||||
password
|
||||
})
|
||||
})
|
||||
fetch("/api/file/createShareLink.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder, file: file.name, expirationValue: value, expirationUnit: unit, password })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.token) {
|
||||
@@ -122,349 +102,562 @@ export function openShareModal(file, folder) {
|
||||
document.getElementById("shareLinkInput").value = url;
|
||||
document.getElementById("shareLinkDisplay").style.display = "block";
|
||||
} else {
|
||||
showToast(t("error_generating_share") + ": " + (data.error||"Unknown"));
|
||||
showToast(t("error_generating_share") + ": " + (data.error || "Unknown"));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
showToast(t("error_generating_share"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Copy to clipboard
|
||||
document.getElementById("copyShareLinkBtn")
|
||||
.addEventListener("click", () => {
|
||||
const input = document.getElementById("shareLinkInput");
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
showToast(t("link_copied"));
|
||||
});
|
||||
document.getElementById("copyShareLinkBtn").addEventListener("click", () => {
|
||||
const input = document.getElementById("shareLinkInput");
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
showToast(t("link_copied"));
|
||||
});
|
||||
}
|
||||
|
||||
export function previewFile(fileUrl, fileName) {
|
||||
let modal = document.getElementById("filePreviewModal");
|
||||
if (!modal) {
|
||||
modal = document.createElement("div");
|
||||
modal.id = "filePreviewModal";
|
||||
Object.assign(modal.style, {
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: "1000"
|
||||
});
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content image-preview-modal-content" style="position: relative; max-width: 90vw; max-height: 90vh;">
|
||||
<span id="closeFileModal" class="close-image-modal" style="position: absolute; top: 10px; right: 10px; font-size: 24px; cursor: pointer;">×</span>
|
||||
<h4 class="image-modal-header"></h4>
|
||||
<div class="file-preview-container" style="position: relative; text-align: center;"></div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
/* -------------------------------- Media modal viewer -------------------------------- */
|
||||
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
|
||||
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
|
||||
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
|
||||
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 closeModal() {
|
||||
const mediaElements = modal.querySelectorAll("video, audio");
|
||||
mediaElements.forEach(media => {
|
||||
media.pause();
|
||||
if (media.tagName.toLowerCase() !== 'video') {
|
||||
try { media.currentTime = 0; } catch (e) { }
|
||||
}
|
||||
});
|
||||
modal.remove();
|
||||
}
|
||||
function 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';
|
||||
}
|
||||
|
||||
document.getElementById("closeFileModal").addEventListener("click", closeModal);
|
||||
modal.addEventListener("click", function (e) {
|
||||
if (e.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
function ensureMediaModal() {
|
||||
let overlay = document.getElementById("filePreviewModal");
|
||||
if (overlay) return overlay;
|
||||
|
||||
overlay = document.createElement("div");
|
||||
overlay.id = "filePreviewModal";
|
||||
Object.assign(overlay.style, {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: "1000"
|
||||
});
|
||||
|
||||
const root = document.documentElement;
|
||||
const styles = getComputedStyle(root);
|
||||
const isDark = root.classList.contains('dark-mode');
|
||||
const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#121212' : '#ffffff');
|
||||
const textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111');
|
||||
|
||||
const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)';
|
||||
const navFg = '#fff';
|
||||
const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)';
|
||||
|
||||
// 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;
|
||||
width: 92vw;
|
||||
max-height: 92vh;
|
||||
height: 92vh;
|
||||
box-sizing: border-box;
|
||||
background: ${panelBg};
|
||||
color: ${textCol};
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
display:flex; flex-direction:column;
|
||||
">
|
||||
<!-- 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>
|
||||
<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 transparent; background:transparent; color:inherit;"></span>
|
||||
<div class="action-group" style="display:flex; gap:8px; align-items:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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: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: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';
|
||||
}
|
||||
modal.querySelector("h4").textContent = fileName;
|
||||
const container = modal.querySelector(".file-preview-container");
|
||||
container.innerHTML = "";
|
||||
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);
|
||||
|
||||
const extension = fileName.split('.').pop().toLowerCase();
|
||||
const isImage = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(fileName);
|
||||
function closeModal() {
|
||||
try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {}
|
||||
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
|
||||
overlay.remove();
|
||||
}
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function setTitle(overlay, 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';
|
||||
}
|
||||
}
|
||||
|
||||
// Topbar icon (theme-aware) used for image tools + video actions
|
||||
function makeTopIcon(name, title) {
|
||||
const b = document.createElement('button');
|
||||
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',
|
||||
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 ? 'flex' : 'none';
|
||||
next.style.display = showNext ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function setRowWatchedBadge(name, watched) {
|
||||
try {
|
||||
const cell = document.querySelector(`tr[data-file-name="${CSS.escape(name)}"] .name-cell`);
|
||||
if (!cell) return;
|
||||
const old = cell.querySelector('.status-badge.watched');
|
||||
if (watched) {
|
||||
if (!old) {
|
||||
const b = document.createElement('span');
|
||||
b.className = 'status-badge watched';
|
||||
b.textContent = t("watched") || t("viewed") || "Watched";
|
||||
b.style.marginLeft = "6px";
|
||||
cell.appendChild(b);
|
||||
}
|
||||
} else if (old) {
|
||||
old.remove();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/* -------------------------------- Entry -------------------------------- */
|
||||
export function previewFile(fileUrl, fileName) {
|
||||
const overlay = ensureMediaModal();
|
||||
const container = overlay.querySelector(".file-preview-container");
|
||||
const actionWrap = overlay.querySelector(".media-right .action-group");
|
||||
const statusChip = overlay.querySelector(".media-right .status-chip");
|
||||
|
||||
// replace nav buttons to clear old listeners
|
||||
let prevBtn = overlay.querySelector('.nav-left');
|
||||
let nextBtn = overlay.querySelector('.nav-right');
|
||||
const newPrev = prevBtn.cloneNode(true);
|
||||
const newNext = nextBtn.cloneNode(true);
|
||||
prevBtn.replaceWith(newPrev);
|
||||
nextBtn.replaceWith(newNext);
|
||||
prevBtn = newPrev; nextBtn = newNext;
|
||||
|
||||
// reset
|
||||
container.innerHTML = "";
|
||||
actionWrap.innerHTML = "";
|
||||
if (statusChip) statusChip.style.display = 'none';
|
||||
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
|
||||
overlay._onKey = null;
|
||||
|
||||
const folder = window.currentFolder || 'root';
|
||||
const name = fileName;
|
||||
const lower = (name || '').toLowerCase();
|
||||
const isImage = IMG_RE.test(lower);
|
||||
const isVideo = VID_RE.test(lower);
|
||||
const isAudio = AUD_RE.test(lower);
|
||||
|
||||
setTitle(overlay, name);
|
||||
|
||||
/* -------------------- IMAGES -------------------- */
|
||||
if (isImage) {
|
||||
// Create the image element with default transform data.
|
||||
const img = document.createElement("img");
|
||||
img.src = fileUrl;
|
||||
img.className = "image-modal-img";
|
||||
img.style.maxWidth = "80vw";
|
||||
img.style.maxHeight = "80vh";
|
||||
img.style.maxWidth = "88vw";
|
||||
img.style.maxHeight = "88vh";
|
||||
img.style.transition = "transform 0.3s ease";
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
img.style.position = 'relative';
|
||||
img.style.zIndex = '1';
|
||||
container.appendChild(img);
|
||||
|
||||
// Filter gallery images for navigation.
|
||||
const images = fileData.filter(file => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name));
|
||||
// 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);
|
||||
actionWrap.appendChild(rotateRight);
|
||||
|
||||
// Create a flex wrapper to hold left panel, center image, and right panel.
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'image-wrapper';
|
||||
wrapper.style.display = 'flex';
|
||||
wrapper.style.alignItems = 'center';
|
||||
wrapper.style.justifyContent = 'center';
|
||||
wrapper.style.position = 'relative';
|
||||
zoomInBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let s = parseFloat(img.dataset.scale) || 1; s += 0.1;
|
||||
img.dataset.scale = s;
|
||||
img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`;
|
||||
});
|
||||
zoomOutBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let s = parseFloat(img.dataset.scale) || 1; s = Math.max(0.1, s - 0.1);
|
||||
img.dataset.scale = s;
|
||||
img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`;
|
||||
});
|
||||
rotateLeft.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let r = parseFloat(img.dataset.rotate) || 0; r = (r - 90 + 360) % 360;
|
||||
img.dataset.rotate = r;
|
||||
img.style.transform = `scale(${img.dataset.scale}) rotate(${r}deg)`;
|
||||
});
|
||||
rotateRight.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let r = parseFloat(img.dataset.rotate) || 0; r = (r + 90) % 360;
|
||||
img.dataset.rotate = r;
|
||||
img.style.transform = `scale(${img.dataset.scale}) rotate(${r}deg)`;
|
||||
});
|
||||
|
||||
// --- Left Panel: Contains Zoom controls (top) and Prev button (bottom) ---
|
||||
const leftPanel = document.createElement('div');
|
||||
leftPanel.className = 'left-panel';
|
||||
leftPanel.style.display = 'flex';
|
||||
leftPanel.style.flexDirection = 'column';
|
||||
leftPanel.style.justifyContent = 'space-between';
|
||||
leftPanel.style.alignItems = 'center';
|
||||
leftPanel.style.width = '60px';
|
||||
leftPanel.style.height = '100%';
|
||||
leftPanel.style.zIndex = '10';
|
||||
const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name));
|
||||
overlay.mediaType = 'image';
|
||||
overlay.mediaList = images;
|
||||
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, images.length > 1, images.length > 1);
|
||||
|
||||
// Top container for zoom buttons.
|
||||
const leftTop = document.createElement('div');
|
||||
leftTop.style.display = 'flex';
|
||||
leftTop.style.flexDirection = 'column';
|
||||
leftTop.style.gap = '4px';
|
||||
// Zoom In button.
|
||||
const zoomInBtn = document.createElement('button');
|
||||
zoomInBtn.className = 'material-icons zoom_in';
|
||||
zoomInBtn.title = 'Zoom In';
|
||||
zoomInBtn.style.background = 'transparent';
|
||||
zoomInBtn.style.border = 'none';
|
||||
zoomInBtn.style.cursor = 'pointer';
|
||||
zoomInBtn.textContent = 'zoom_in';
|
||||
// Zoom Out button.
|
||||
const zoomOutBtn = document.createElement('button');
|
||||
zoomOutBtn.className = 'material-icons zoom_out';
|
||||
zoomOutBtn.title = 'Zoom Out';
|
||||
zoomOutBtn.style.background = 'transparent';
|
||||
zoomOutBtn.style.border = 'none';
|
||||
zoomOutBtn.style.cursor = 'pointer';
|
||||
zoomOutBtn.textContent = 'zoom_out';
|
||||
leftTop.appendChild(zoomInBtn);
|
||||
leftTop.appendChild(zoomOutBtn);
|
||||
leftPanel.appendChild(leftTop);
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const newFile = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setTitle(overlay, newFile);
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
img.style.transform = 'scale(1) rotate(0deg)';
|
||||
img.src = buildPreviewUrl(folder, newFile);
|
||||
};
|
||||
|
||||
// Bottom container for prev button.
|
||||
const leftBottom = document.createElement('div');
|
||||
leftBottom.style.display = 'flex';
|
||||
leftBottom.style.justifyContent = 'center';
|
||||
leftBottom.style.alignItems = 'center';
|
||||
leftBottom.style.width = '100%';
|
||||
if (images.length > 1) {
|
||||
const prevBtn = document.createElement("button");
|
||||
prevBtn.textContent = "‹";
|
||||
prevBtn.className = "gallery-nav-btn";
|
||||
prevBtn.style.background = 'transparent';
|
||||
prevBtn.style.border = 'none';
|
||||
prevBtn.style.color = 'white';
|
||||
prevBtn.style.fontSize = '48px';
|
||||
prevBtn.style.cursor = 'pointer';
|
||||
prevBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
// Safety check:
|
||||
if (!modal.galleryImages || modal.galleryImages.length === 0) return;
|
||||
modal.galleryCurrentIndex = (modal.galleryCurrentIndex - 1 + modal.galleryImages.length) % modal.galleryImages.length;
|
||||
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
|
||||
modal.querySelector("h4").textContent = newFile.name;
|
||||
img.src = ((window.currentFolder === "root")
|
||||
? "uploads/"
|
||||
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
|
||||
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
|
||||
// Reset transforms.
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
img.style.transform = 'scale(1) rotate(0deg)';
|
||||
});
|
||||
leftBottom.appendChild(prevBtn);
|
||||
} else {
|
||||
// Insert an empty placeholder for consistent layout.
|
||||
leftBottom.innerHTML = ' ';
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||
const onKey = (e) => {
|
||||
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
|
||||
if (e.key === "ArrowLeft") navigate(-1);
|
||||
if (e.key === "ArrowRight") navigate(+1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
overlay._onKey = onKey;
|
||||
}
|
||||
leftPanel.appendChild(leftBottom);
|
||||
|
||||
// --- Center Panel: Contains the image ---
|
||||
const centerPanel = document.createElement('div');
|
||||
centerPanel.className = 'center-image-container';
|
||||
centerPanel.style.flexGrow = '1';
|
||||
centerPanel.style.textAlign = 'center';
|
||||
centerPanel.style.position = 'relative';
|
||||
centerPanel.style.zIndex = '1';
|
||||
centerPanel.appendChild(img);
|
||||
overlay.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Right Panel: Contains Rotate controls (top) and Next button (bottom) ---
|
||||
const rightPanel = document.createElement('div');
|
||||
rightPanel.className = 'right-panel';
|
||||
rightPanel.style.display = 'flex';
|
||||
rightPanel.style.flexDirection = 'column';
|
||||
rightPanel.style.justifyContent = 'space-between';
|
||||
rightPanel.style.alignItems = 'center';
|
||||
rightPanel.style.width = '60px';
|
||||
rightPanel.style.height = '100%';
|
||||
rightPanel.style.zIndex = '10';
|
||||
|
||||
// Top container for rotate buttons.
|
||||
const rightTop = document.createElement('div');
|
||||
rightTop.style.display = 'flex';
|
||||
rightTop.style.flexDirection = 'column';
|
||||
rightTop.style.gap = '4px';
|
||||
// Rotate Left button.
|
||||
const rotateLeftBtn = document.createElement('button');
|
||||
rotateLeftBtn.className = 'material-icons rotate_left';
|
||||
rotateLeftBtn.title = 'Rotate Left';
|
||||
rotateLeftBtn.style.background = 'transparent';
|
||||
rotateLeftBtn.style.border = 'none';
|
||||
rotateLeftBtn.style.cursor = 'pointer';
|
||||
rotateLeftBtn.textContent = 'rotate_left';
|
||||
// Rotate Right button.
|
||||
const rotateRightBtn = document.createElement('button');
|
||||
rotateRightBtn.className = 'material-icons rotate_right';
|
||||
rotateRightBtn.title = 'Rotate Right';
|
||||
rotateRightBtn.style.background = 'transparent';
|
||||
rotateRightBtn.style.border = 'none';
|
||||
rotateRightBtn.style.cursor = 'pointer';
|
||||
rotateRightBtn.textContent = 'rotate_right';
|
||||
rightTop.appendChild(rotateLeftBtn);
|
||||
rightTop.appendChild(rotateRightBtn);
|
||||
rightPanel.appendChild(rightTop);
|
||||
|
||||
// Bottom container for next button.
|
||||
const rightBottom = document.createElement('div');
|
||||
rightBottom.style.display = 'flex';
|
||||
rightBottom.style.justifyContent = 'center';
|
||||
rightBottom.style.alignItems = 'center';
|
||||
rightBottom.style.width = '100%';
|
||||
if (images.length > 1) {
|
||||
const nextBtn = document.createElement("button");
|
||||
nextBtn.textContent = "›";
|
||||
nextBtn.className = "gallery-nav-btn";
|
||||
nextBtn.style.background = 'transparent';
|
||||
nextBtn.style.border = 'none';
|
||||
nextBtn.style.color = 'white';
|
||||
nextBtn.style.fontSize = '48px';
|
||||
nextBtn.style.cursor = 'pointer';
|
||||
nextBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
// Safety check:
|
||||
if (!modal.galleryImages || modal.galleryImages.length === 0) return;
|
||||
modal.galleryCurrentIndex = (modal.galleryCurrentIndex + 1) % modal.galleryImages.length;
|
||||
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
|
||||
modal.querySelector("h4").textContent = newFile.name;
|
||||
img.src = ((window.currentFolder === "root")
|
||||
? "uploads/"
|
||||
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
|
||||
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
|
||||
// Reset transforms.
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
img.style.transform = 'scale(1) rotate(0deg)';
|
||||
});
|
||||
rightBottom.appendChild(nextBtn);
|
||||
} else {
|
||||
// Insert a placeholder so that center remains properly aligned.
|
||||
rightBottom.innerHTML = ' ';
|
||||
}
|
||||
rightPanel.appendChild(rightBottom);
|
||||
|
||||
// Assemble panels into the wrapper.
|
||||
wrapper.appendChild(leftPanel);
|
||||
wrapper.appendChild(centerPanel);
|
||||
wrapper.appendChild(rightPanel);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// --- Set up zoom controls event listeners ---
|
||||
zoomInBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
let scale = parseFloat(img.dataset.scale) || 1;
|
||||
scale += 0.1;
|
||||
img.dataset.scale = scale;
|
||||
img.style.transform = 'scale(' + scale + ') rotate(' + img.dataset.rotate + 'deg)';
|
||||
});
|
||||
zoomOutBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
let scale = parseFloat(img.dataset.scale) || 1;
|
||||
scale = Math.max(0.1, scale - 0.1);
|
||||
img.dataset.scale = scale;
|
||||
img.style.transform = 'scale(' + scale + ') rotate(' + img.dataset.rotate + 'deg)';
|
||||
});
|
||||
|
||||
// Attach rotation control listeners (always present now).
|
||||
rotateLeftBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
let rotate = parseFloat(img.dataset.rotate) || 0;
|
||||
rotate = (rotate - 90 + 360) % 360;
|
||||
img.dataset.rotate = rotate;
|
||||
img.style.transform = 'scale(' + img.dataset.scale + ') rotate(' + rotate + 'deg)';
|
||||
});
|
||||
rotateRightBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
let rotate = parseFloat(img.dataset.rotate) || 0;
|
||||
rotate = (rotate + 90) % 360;
|
||||
img.dataset.rotate = rotate;
|
||||
img.style.transform = 'scale(' + img.dataset.scale + ') rotate(' + rotate + 'deg)';
|
||||
});
|
||||
|
||||
// Save gallery details if there is more than one image.
|
||||
if (images.length > 1) {
|
||||
modal.galleryImages = images;
|
||||
modal.galleryCurrentIndex = images.findIndex(f => f.name === fileName);
|
||||
}
|
||||
} else {
|
||||
// Handle non-image file previews.
|
||||
if (extension === "pdf") {
|
||||
// build a cache‐busted URL
|
||||
/* -------------------- PDF => new tab -------------------- */
|
||||
if (lower.endsWith('.pdf')) {
|
||||
const separator = fileUrl.includes('?') ? '&' : '?';
|
||||
const urlWithTs = fileUrl + separator + 't=' + Date.now();
|
||||
|
||||
// open in a new tab (avoids CSP frame-ancestors)
|
||||
window.open(urlWithTs, "_blank");
|
||||
|
||||
// tear down the just-created modal
|
||||
const modal = document.getElementById("filePreviewModal");
|
||||
if (modal) modal.remove();
|
||||
|
||||
// stop further preview logic
|
||||
overlay.remove();
|
||||
return;
|
||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) {
|
||||
const video = document.createElement("video");
|
||||
video.src = fileUrl;
|
||||
video.controls = true;
|
||||
video.className = "image-modal-img";
|
||||
|
||||
const progressKey = 'videoProgress-' + fileUrl;
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
const savedTime = localStorage.getItem(progressKey);
|
||||
if (savedTime) {
|
||||
video.currentTime = parseFloat(savedTime);
|
||||
}
|
||||
|
||||
/* -------------------- VIDEOS -------------------- */
|
||||
if (isVideo) {
|
||||
let video = document.createElement("video"); // let so we can rebind
|
||||
video.controls = true;
|
||||
video.style.maxWidth = "88vw";
|
||||
video.style.maxHeight = "88vh";
|
||||
video.style.objectFit = "contain";
|
||||
container.appendChild(video);
|
||||
|
||||
// Top-right action icons (Material icons, theme-aware)
|
||||
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||
actionWrap.appendChild(markBtnIcon);
|
||||
actionWrap.appendChild(clearBtnIcon);
|
||||
|
||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||
overlay.mediaType = 'video';
|
||||
overlay.mediaList = videos;
|
||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||
|
||||
const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); };
|
||||
|
||||
const SAVE_INTERVAL_MS = 5000;
|
||||
let lastSaveAt = 0;
|
||||
let pending = false;
|
||||
|
||||
async function getProgress(nm) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return data && data.state ? data.state : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||
try {
|
||||
pending = true;
|
||||
const res = await fetch("/api/media/updateProgress.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||
});
|
||||
const data = await res.json();
|
||||
pending = false;
|
||||
return data;
|
||||
} catch (e) { pending = false; console.error(e); return null; }
|
||||
}
|
||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||
|
||||
function renderStatus(state) {
|
||||
if (!statusChip) return;
|
||||
// Completed
|
||||
if (state && state.completed) {
|
||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||
statusChip.style.display = 'inline-block';
|
||||
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||
statusChip.style.color = '#22c55e';
|
||||
markBtnIcon.style.display = 'none';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
// In progress
|
||||
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
statusChip.textContent = `${pct}%`;
|
||||
statusChip.style.display = 'inline-block';
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
|
||||
statusChip.style.color = ORANGE_HEX;
|
||||
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
|
||||
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
// No progress
|
||||
statusChip.style.display = 'none';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = 'none';
|
||||
}
|
||||
|
||||
function bindVideoEvents(nm) {
|
||||
const nv = video.cloneNode(true);
|
||||
video.replaceWith(nv);
|
||||
video = nv;
|
||||
|
||||
video.addEventListener("loadedmetadata", async () => {
|
||||
try {
|
||||
const state = await getProgress(nm);
|
||||
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
||||
video.currentTime = state.seconds;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
||||
} else {
|
||||
const ls = localStorage.getItem(lsKey(nm));
|
||||
if (ls) video.currentTime = parseFloat(ls);
|
||||
}
|
||||
renderStatus(state || null);
|
||||
} catch {
|
||||
renderStatus(null);
|
||||
}
|
||||
});
|
||||
video.addEventListener("timeupdate", () => {
|
||||
localStorage.setItem(progressKey, video.currentTime);
|
||||
|
||||
video.addEventListener("timeupdate", async () => {
|
||||
const now = Date.now();
|
||||
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
|
||||
lastSaveAt = now;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
sendProgress({ nm, seconds, duration });
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
|
||||
renderStatus({ seconds, duration, completed: false });
|
||||
});
|
||||
video.addEventListener("ended", () => {
|
||||
localStorage.removeItem(progressKey);
|
||||
|
||||
video.addEventListener("ended", async () => {
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
});
|
||||
container.appendChild(video);
|
||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(fileName)) {
|
||||
const audio = document.createElement("audio");
|
||||
audio.src = fileUrl;
|
||||
audio.controls = true;
|
||||
audio.className = "audio-modal";
|
||||
audio.style.maxWidth = "80vw";
|
||||
container.appendChild(audio);
|
||||
} else {
|
||||
container.textContent = "Preview not available for this file type.";
|
||||
|
||||
markBtnIcon.onclick = async () => {
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
};
|
||||
clearBtnIcon.onclick = async () => {
|
||||
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("progress_cleared") || "Progress cleared");
|
||||
setFileWatchedBadge(nm, false);
|
||||
renderStatus(null);
|
||||
};
|
||||
}
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const nm = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setVideoSrc(nm);
|
||||
bindVideoEvents(nm);
|
||||
};
|
||||
|
||||
if (videos.length > 1) {
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||
const onKey = (e) => {
|
||||
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
|
||||
if (e.key === "ArrowLeft") navigate(-1);
|
||||
if (e.key === "ArrowRight") navigate(+1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
overlay._onKey = onKey;
|
||||
}
|
||||
|
||||
setVideoSrc(name);
|
||||
renderStatus(null);
|
||||
bindVideoEvents(name);
|
||||
overlay.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
/* -------------------- AUDIO / OTHER -------------------- */
|
||||
if (isAudio) {
|
||||
const audio = document.createElement("audio");
|
||||
audio.src = fileUrl;
|
||||
audio.controls = true;
|
||||
audio.className = "audio-modal";
|
||||
audio.style.maxWidth = "88vw";
|
||||
container.appendChild(audio);
|
||||
overlay.style.display = "flex";
|
||||
} else {
|
||||
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
|
||||
overlay.style.display = "flex";
|
||||
}
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
// Preserve original functionality.
|
||||
/* -------------------------------- Small display helper -------------------------------- */
|
||||
export function displayFilePreview(file, container) {
|
||||
const actualFile = file.file || file;
|
||||
if (!(actualFile instanceof File)) {
|
||||
@@ -472,10 +665,9 @@ export function displayFilePreview(file, container) {
|
||||
return;
|
||||
}
|
||||
container.style.display = "inline-block";
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(actualFile.name)) {
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
if (IMG_RE.test(actualFile.name)) {
|
||||
const img = document.createElement("img");
|
||||
img.src = URL.createObjectURL(actualFile);
|
||||
img.classList.add("file-preview-img");
|
||||
@@ -488,5 +680,6 @@ export function displayFilePreview(file, container) {
|
||||
}
|
||||
}
|
||||
|
||||
// expose for HTML onclick usage
|
||||
window.previewFile = previewFile;
|
||||
window.openShareModal = openShareModal;
|
||||
@@ -302,7 +302,23 @@ const translations = {
|
||||
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
|
||||
"context_move_folder": "Move Folder...",
|
||||
"context_move_here": "Move Here",
|
||||
"context_move_cancel": "Cancel Move"
|
||||
"context_move_cancel": "Cancel Move",
|
||||
"mark_as_viewed": "Mark as viewed",
|
||||
"viewed": "Viewed",
|
||||
"resumed_from": "Resumed from",
|
||||
"clear_progress": "Clear progress",
|
||||
"marked_viewed": "Marked as viewed",
|
||||
"progress_cleared": "Progress cleared",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"watched": "Watched",
|
||||
"reset_progress": "Reset Progress",
|
||||
"color_folder": "Color folder",
|
||||
"choose_color": "Choose a color",
|
||||
"reset_default": "Reset",
|
||||
"save_color": "Save",
|
||||
"folder_color_saved": "Folder color saved.",
|
||||
"folder_color_cleared": "Folder color reset."
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
@@ -67,32 +67,25 @@ function isDemoHost() {
|
||||
}
|
||||
|
||||
function showLoginTip(message) {
|
||||
const form = document.getElementById('loginForm');
|
||||
if (!form) return;
|
||||
|
||||
let tip = document.getElementById('fr-login-tip');
|
||||
if (!tip) {
|
||||
tip = document.createElement('div');
|
||||
tip.id = 'fr-login-tip';
|
||||
tip.className = 'alert alert-info'; // fine even without Bootstrap
|
||||
tip.style.marginTop = '8px';
|
||||
form.prepend(tip);
|
||||
}
|
||||
|
||||
// Clear & rebuild so we can add the demo hint cleanly
|
||||
tip.textContent = '';
|
||||
tip.append(document.createTextNode(message || ''));
|
||||
|
||||
if (isDemoHost()) {
|
||||
const line = document.createElement('div');
|
||||
line.style.marginTop = '6px';
|
||||
const mk = (txt) => { const k = document.createElement('code'); k.textContent = txt; return k; };
|
||||
line.append(
|
||||
document.createTextNode('Demo login — user: '), mk('demo'),
|
||||
document.createTextNode(' · pass: '), mk('demo')
|
||||
);
|
||||
const tip = document.getElementById('fr-login-tip');
|
||||
if (!tip) return;
|
||||
tip.innerHTML = ''; // clear
|
||||
if (message) tip.append(document.createTextNode(message));
|
||||
if (location.hostname.replace(/^www\./, '') === 'demo.filerise.net') {
|
||||
const line = document.createElement('div'); line.style.marginTop = '6px';
|
||||
const mk = t => { const k = document.createElement('code'); k.textContent = t; return k; };
|
||||
line.append(document.createTextNode('Demo login — user: '), mk('demo'),
|
||||
document.createTextNode(' · pass: '), mk('demo'));
|
||||
tip.append(line);
|
||||
}
|
||||
tip.style.display = 'block'; // reveal without shifting layout
|
||||
}
|
||||
|
||||
async function hideOverlaySmoothly(overlay) {
|
||||
if (!overlay) return;
|
||||
try { await document.fonts?.ready; } catch { }
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
function wireModalEnterDefault() {
|
||||
@@ -322,7 +315,6 @@ function applyDarkMode({ fromSystemChange = false } = {}) {
|
||||
let stored = null;
|
||||
try { stored = localStorage.getItem('darkMode'); } catch { }
|
||||
|
||||
// If no stored pref, fall back to system
|
||||
let isDark = (stored === null)
|
||||
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
: (stored === '1' || stored === 'true');
|
||||
@@ -336,15 +328,26 @@ function applyDarkMode({ fromSystemChange = false } = {}) {
|
||||
el.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
// keep UA chrome & bg consistent post-toggle
|
||||
const bg = isDark ? '#121212' : '#ffffff';
|
||||
root.style.backgroundColor = bg;
|
||||
root.style.colorScheme = isDark ? 'dark' : 'light';
|
||||
if (body) {
|
||||
body.style.backgroundColor = bg;
|
||||
body.style.colorScheme = isDark ? 'dark' : 'light';
|
||||
}
|
||||
const mt = document.querySelector('meta[name="theme-color"]');
|
||||
if (mt) mt.content = bg;
|
||||
const mcs = document.querySelector('meta[name="color-scheme"]');
|
||||
if (mcs) mcs.content = isDark ? 'dark light' : 'light dark';
|
||||
|
||||
const btn = document.getElementById('darkModeToggle');
|
||||
const icon = document.getElementById('darkModeIcon');
|
||||
if (icon) icon.textContent = isDark ? 'light_mode' : 'dark_mode';
|
||||
|
||||
if (btn) {
|
||||
const ttOn = (typeof t === 'function' ? t('switch_to_dark_mode') : 'Switch to dark mode');
|
||||
const ttOff = (typeof t === 'function' ? t('switch_to_light_mode') : 'Switch to light mode');
|
||||
const aria = (typeof t === 'function' ? (isDark ? t('light_mode') : t('dark_mode')) : (isDark ? 'Light mode' : 'Dark mode'));
|
||||
|
||||
btn.classList.toggle('active', isDark);
|
||||
btn.setAttribute('aria-label', aria);
|
||||
btn.setAttribute('title', isDark ? ttOff : ttOn);
|
||||
@@ -381,6 +384,9 @@ function bindDarkMode() {
|
||||
// ---------- tiny utils ----------
|
||||
const $ = (s, root = document) => root.querySelector(s);
|
||||
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
|
||||
// Safe show/hide that work with both CSS and [hidden]
|
||||
const unhide = (el) => { if (!el) return; el.removeAttribute('hidden'); el.style.display = ''; };
|
||||
const hideEl = (el) => { if (!el) return; el.setAttribute('hidden', ''); el.style.display = 'none'; };
|
||||
const show = (el) => {
|
||||
if (!el) return;
|
||||
el.hidden = false; el.classList?.remove('d-none', 'hidden');
|
||||
@@ -394,28 +400,106 @@ function bindDarkMode() {
|
||||
};
|
||||
|
||||
// ---------- site config / auth ----------
|
||||
function applySiteConfig(cfg) {
|
||||
function applySiteConfig(cfg, { phase = 'final' } = {}) {
|
||||
try {
|
||||
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
|
||||
|
||||
// Always keep <title> correct early (no visual flicker)
|
||||
document.title = title;
|
||||
const h1 = document.querySelector('.header-title h1'); if (h1) h1.textContent = title;
|
||||
|
||||
// --- Login options (apply in BOTH phases so login page is correct) ---
|
||||
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
||||
const disableForm = !!lo.disableFormLogin;
|
||||
const disableOIDC = !!lo.disableOIDCLogin;
|
||||
const disableBasic = !!lo.disableBasicAuth;
|
||||
|
||||
const row = $('#loginForm'); if (row) row.style.display = disableForm ? 'none' : '';
|
||||
|
||||
// be tolerant to key variants just in case
|
||||
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
|
||||
|
||||
const showForm = !disableForm;
|
||||
const showOIDC = !disableOIDC;
|
||||
const showBasic = !disableBasic;
|
||||
|
||||
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
|
||||
const authForm = $('#authForm'); // inner username/password form
|
||||
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
|
||||
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||
|
||||
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
|
||||
if (loginWrap) {
|
||||
const anyMethod = showForm || showOIDC || showBasic;
|
||||
if (anyMethod) {
|
||||
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
|
||||
loginWrap.style.display = ''; // let CSS decide
|
||||
} else {
|
||||
loginWrap.setAttribute('hidden', '');
|
||||
loginWrap.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Toggle the pieces inside the wrapper
|
||||
if (authForm) authForm.style.display = showForm ? '' : 'none';
|
||||
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
|
||||
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
|
||||
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
||||
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
||||
|
||||
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
|
||||
if (phase === 'final') {
|
||||
const h1 = document.querySelector('.header-title h1');
|
||||
if (h1) {
|
||||
// prevent i18n or legacy from overwriting it
|
||||
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
|
||||
|
||||
if (h1.textContent !== title) h1.textContent = title;
|
||||
|
||||
// lock it so late code can't stomp it
|
||||
if (!h1.__titleLock) {
|
||||
const mo = new MutationObserver(() => {
|
||||
if (h1.textContent !== title) h1.textContent = title;
|
||||
});
|
||||
mo.observe(h1, { childList: true, characterData: true, subtree: true });
|
||||
h1.__titleLock = mo;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
async function readyToReveal() {
|
||||
// Wait for CSS + fonts so the first revealed frame is fully styled
|
||||
try { await (window.__CSS_PROMISE__ || Promise.resolve()); } catch { }
|
||||
try { await document.fonts?.ready; } catch { }
|
||||
// Give layout one paint to settle
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
}
|
||||
|
||||
async function revealAppAndHideOverlay() {
|
||||
const appRoot = document.getElementById('appRoot');
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
await readyToReveal();
|
||||
if (appRoot) appRoot.style.visibility = 'visible';
|
||||
if (overlay) {
|
||||
overlay.style.transition = 'opacity .18s ease-out';
|
||||
overlay.style.opacity = '0';
|
||||
setTimeout(() => { overlay.style.display = 'none'; }, 220);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSiteConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/siteConfig.php', { credentials: 'include' });
|
||||
const j = await r.json().catch(() => ({})); applySiteConfig(j);
|
||||
} catch { applySiteConfig({}); }
|
||||
const j = await r.json().catch(() => ({}));
|
||||
window.__FR_SITE_CFG__ = j || {};
|
||||
// Early pass: title + login options (skip touching <h1> to avoid flicker)
|
||||
applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' });
|
||||
return window.__FR_SITE_CFG__;
|
||||
} catch {
|
||||
window.__FR_SITE_CFG__ = {};
|
||||
applySiteConfig({}, { phase: 'early' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function primeCsrf() {
|
||||
try {
|
||||
@@ -665,7 +749,6 @@ function bindDarkMode() {
|
||||
function forceLoginVisible() {
|
||||
show($('#main'));
|
||||
show($('#loginForm'));
|
||||
hide($('.main-wrapper'));
|
||||
const hb = $('.header-buttons'); if (hb) hb.style.visibility = 'hidden';
|
||||
const ov = $('#loadingOverlay'); if (ov) ov.style.display = 'none';
|
||||
}
|
||||
@@ -809,8 +892,7 @@ function bindDarkMode() {
|
||||
window.__FR_FLAGS.booted = true;
|
||||
ensureToastReady();
|
||||
// show chrome
|
||||
const wrap = document.querySelector('.main-wrapper'); if (wrap) { wrap.hidden = false; wrap.classList?.remove('d-none', 'hidden'); wrap.style.display = 'block'; }
|
||||
const lf = document.getElementById('loginForm'); if (lf) lf.style.display = 'none';
|
||||
|
||||
const hb = document.querySelector('.header-buttons'); if (hb) hb.style.visibility = 'visible';
|
||||
const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'flex';
|
||||
|
||||
@@ -825,6 +907,9 @@ function bindDarkMode() {
|
||||
window.__FR_AUTH_STATE = state;
|
||||
} catch { }
|
||||
|
||||
// authed → heavy boot path
|
||||
document.body.classList.add('authed');
|
||||
|
||||
// 1) i18n (safe)
|
||||
// i18n: honor saved language first, then apply translations
|
||||
try {
|
||||
@@ -840,10 +925,20 @@ function bindDarkMode() {
|
||||
if (!window.__FR_FLAGS.initialized) {
|
||||
if (typeof app.loadCsrfToken === 'function') await app.loadCsrfToken();
|
||||
if (typeof app.initializeApp === 'function') app.initializeApp();
|
||||
const darkBtn = document.getElementById('darkModeToggle');
|
||||
if (darkBtn) {
|
||||
darkBtn.removeAttribute('hidden');
|
||||
darkBtn.style.setProperty('display', 'inline-flex', 'important'); // beats any CSS
|
||||
darkBtn.style.visibility = ''; // just in case
|
||||
}
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/css/vendor/material-icons.css?v={{APP_QVER}}';
|
||||
document.head.appendChild(link);
|
||||
|
||||
|
||||
window.__FR_FLAGS.initialized = true;
|
||||
|
||||
// Show "Welcome back, <username>!" only once per tab-session
|
||||
try {
|
||||
if (!sessionStorage.getItem('__fr_welcomed')) {
|
||||
const name = (window.__FR_AUTH_STATE?.username) || localStorage.getItem('username') || '';
|
||||
@@ -864,7 +959,7 @@ function bindDarkMode() {
|
||||
auth.applyProxyBypassUI && auth.applyProxyBypassUI();
|
||||
auth.updateAuthenticatedUI && auth.updateAuthenticatedUI(state);
|
||||
|
||||
// ⬇️ bind ALL the admin / change-password buttons once
|
||||
// bind ALL the admin / change-password buttons once
|
||||
if (!window.__FR_FLAGS.wired.authInit && typeof auth.initAuth === 'function') {
|
||||
try { auth.initAuth(); } catch (e) { console.warn('[auth] initAuth failed', e); }
|
||||
window.__FR_FLAGS.wired.authInit = true;
|
||||
@@ -913,36 +1008,134 @@ function bindDarkMode() {
|
||||
|
||||
// ---------- entry (no flicker: decide state BEFORE showing login) ----------
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
|
||||
if (window.__FR_FLAGS.entryStarted) return;
|
||||
window.__FR_FLAGS.entryStarted = true;
|
||||
|
||||
// Always start clean
|
||||
document.body.classList.remove('authed');
|
||||
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
const wrap = document.querySelector('.main-wrapper'); // app shell
|
||||
const mainEl = document.getElementById('main'); // contains loginForm
|
||||
const login = document.getElementById('loginForm');
|
||||
|
||||
bindDarkMode();
|
||||
await loadSiteConfig();
|
||||
|
||||
const { authed, setup } = await checkAuth();
|
||||
|
||||
if (setup) { await bootSetupWizard(); return; }
|
||||
if (authed) { await bootHeavy(); return; }
|
||||
if (setup) {
|
||||
// Setup wizard runs inside app shell
|
||||
unhide(wrap);
|
||||
hideEl(login);
|
||||
await bootSetupWizard();
|
||||
await revealAppAndHideOverlay();
|
||||
|
||||
// login view
|
||||
show(document.querySelector('#main'));
|
||||
show(document.querySelector('#loginForm'));
|
||||
(document.querySelector('.header-buttons') || {}).style && (document.querySelector('.header-buttons').style.visibility = 'hidden');
|
||||
const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (authed) {
|
||||
// Authenticated path: show app, hide login
|
||||
document.body.classList.add('authed');
|
||||
unhide(wrap); // works whether CSS or [hidden] was used
|
||||
hideEl(login);
|
||||
await bootHeavy();
|
||||
await revealAppAndHideOverlay();
|
||||
requestAnimationFrame(() => {
|
||||
const pre = document.getElementById('pretheme-css');
|
||||
if (pre) pre.remove();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- NOT AUTHED: show only the login view ----
|
||||
hideEl(wrap); // ensure app shell stays hidden while logged out
|
||||
unhide(mainEl);
|
||||
unhide(login);
|
||||
if (login) login.style.display = '';
|
||||
// …wire stuff…
|
||||
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
|
||||
// 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';
|
||||
|
||||
// keep app cards inert while logged out (no layout poke)
|
||||
['uploadCard', 'folderManagementCard'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.style.display = 'none';
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
try { el.inert = true; } catch { }
|
||||
});
|
||||
|
||||
bindLogin();
|
||||
wireCreateDropdown();
|
||||
keepCreateDropdownWired();
|
||||
wireModalEnterDefault();
|
||||
showLoginTip('Please log in to continue');
|
||||
|
||||
}, { once: true }); // <— important
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
}, { once: true });
|
||||
})();
|
||||
|
||||
|
||||
// --- Mobile switcher + PWA SW (mobile-only) ---
|
||||
(() => {
|
||||
// keep it simple + robust
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const hasFrAppHint = qs.get('frapp') === '1';
|
||||
|
||||
const isStandalone =
|
||||
(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ||
|
||||
(typeof navigator.standalone === 'boolean' && navigator.standalone);
|
||||
|
||||
const isCapUA = /\bCapacitor\b/i.test(navigator.userAgent);
|
||||
const hasCapBridge = !!(window.Capacitor && window.Capacitor.Plugins);
|
||||
|
||||
// “mobile-ish”: native mobile UAs OR touch + reasonably narrow viewport (covers iPad-on-Mac UA)
|
||||
const isMobileish =
|
||||
/Android|iPhone|iPad|iPod|Mobile|Silk|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
(navigator.maxTouchPoints > 1 && Math.min(screen.width, screen.height) <= 900);
|
||||
|
||||
// load the switcher only in the mobile app, or mobile standalone PWA, or when explicitly hinted
|
||||
const shouldLoadSwitcher =
|
||||
hasCapBridge || isCapUA || (isStandalone && isMobileish) || (hasFrAppHint && isMobileish);
|
||||
|
||||
// expose a flag to inspect later
|
||||
window.FR_APP = !!(hasCapBridge || isCapUA || (isStandalone && isMobileish));
|
||||
|
||||
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
|
||||
|
||||
if (shouldLoadSwitcher) {
|
||||
import(`/js/mobile/switcher.js?v=${encodeURIComponent(QVER)}`)
|
||||
.then(() => {
|
||||
if (hasFrAppHint && !sessionStorage.getItem('frx_opened_once')) {
|
||||
sessionStorage.setItem('frx_opened_once', '1');
|
||||
window.dispatchEvent(new CustomEvent('frx:openSwitcher'));
|
||||
}
|
||||
})
|
||||
.catch(err => console.info('[FileRise] switcher import failed:', err));
|
||||
}
|
||||
|
||||
// SW only for web (https or localhost), never in Capacitor
|
||||
const onHttps = location.protocol === 'https:' || location.hostname === 'localhost';
|
||||
if ('serviceWorker' in navigator && onHttps && !hasCapBridge && !isCapUA) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => { });
|
||||
});
|
||||
}
|
||||
})();
|
||||
365
public/js/mobile/switcher.js
Normal file
@@ -0,0 +1,365 @@
|
||||
(function(){
|
||||
const isCap = !!window.Capacitor || /Capacitor/i.test(navigator.userAgent);
|
||||
if (!isCap) return;
|
||||
// NOTE: allow running inside Capacitor (origin "capacitor://localhost")
|
||||
|
||||
const Plugins = (window.Capacitor && window.Capacitor.Plugins) || {};
|
||||
const Pref = Plugins.Preferences ? {
|
||||
get: ({key}) => Plugins.Preferences.get({key}),
|
||||
set: ({key,value}) => Plugins.Preferences.set({key,value}),
|
||||
remove:({key}) => Plugins.Preferences.remove({key})
|
||||
} : {
|
||||
get: async ({key}) => ({ value: localStorage.getItem(key) || null }),
|
||||
set: async ({key,value}) => localStorage.setItem(key, value),
|
||||
remove: async ({key}) => localStorage.removeItem(key)
|
||||
};
|
||||
const Http = (Plugins.Http || Plugins.CapacitorHttp) || null;
|
||||
|
||||
const K_INST='fr_instances_v1', K_ACTIVE='fr_active_v1', K_STATUS='fr_status_v1';
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
|
||||
// Safe element builder: attributes only, children as nodes/strings (no innerHTML)
|
||||
const el = (tag, attrs = {}, children = []) => {
|
||||
const n = document.createElement(tag);
|
||||
for (const k in attrs) n.setAttribute(k, attrs[k]);
|
||||
(Array.isArray(children) ? children : [children]).forEach(c => {
|
||||
if (c == null) return;
|
||||
n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||
});
|
||||
return n;
|
||||
};
|
||||
|
||||
// Normalize to http(s), strip creds, collapse trailing slashes
|
||||
const normalize = (u) => {
|
||||
if (!u) return '';
|
||||
let v = u.trim();
|
||||
if (!/^https?:\/\//i.test(v)) v = 'https://' + v;
|
||||
try {
|
||||
const url = new URL(v);
|
||||
if (!/^https?:$/.test(url.protocol)) return '';
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
url.pathname = url.pathname.replace(/\/+$/,'');
|
||||
return url.toString();
|
||||
} catch { return ''; }
|
||||
};
|
||||
|
||||
// Append/overwrite a query param safely on a normalized URL
|
||||
const withParam = (base, k, v) => {
|
||||
try {
|
||||
const u = new URL(normalize(base));
|
||||
u.searchParams.set(k, v);
|
||||
return u.toString();
|
||||
} catch { return ''; }
|
||||
};
|
||||
|
||||
const host = u => {
|
||||
try { return new URL(normalize(u)).hostname; } catch { return ''; }
|
||||
};
|
||||
const originOf = u => {
|
||||
try { return new URL(normalize(u)).origin; } catch { return ''; }
|
||||
};
|
||||
const faviconUrl = u => {
|
||||
try { const x = new URL(normalize(u)); return x.origin + '/favicon.ico'; } catch { return ''; }
|
||||
};
|
||||
const initialsIcon = (hn='FR') => {
|
||||
const t=(hn||'FR').replace(/^www\./,'').slice(0,2).toUpperCase();
|
||||
const svg=`<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'>
|
||||
<rect width='100%' height='100%' rx='12' ry='12' fill='#2196F3'/>
|
||||
<text x='50%' y='54%' text-anchor='middle' font-family='system-ui,-apple-system,Segoe UI,Roboto,sans-serif'
|
||||
font-size='28' font-weight='700' fill='#fff'>${t}</text></svg>`;
|
||||
return 'data:image/svg+xml;utf8,'+encodeURIComponent(svg);
|
||||
};
|
||||
|
||||
async function getStatusCache(){
|
||||
const raw=(await Pref.get({key:K_STATUS})).value;
|
||||
try { return raw ? JSON.parse(raw) : {}; } catch { return {}; }
|
||||
}
|
||||
async function writeStatus(origin, ok){
|
||||
const cache=await getStatusCache();
|
||||
cache[origin]={ ok, ts: Date.now() };
|
||||
await Pref.set({key:K_STATUS, value:JSON.stringify(cache)});
|
||||
}
|
||||
|
||||
async function verifyFileRise(u, timeout=5000){
|
||||
if (!u || !Http) return {ok:false};
|
||||
const base = normalize(u), org = originOf(base);
|
||||
const tryJson = async (url, validate) => {
|
||||
try{
|
||||
const r = await Http.get({ url, connectTimeout:timeout, readTimeout:timeout, headers:{'Accept':'application/json','Cache-Control':'no-cache'} });
|
||||
if (r && r.data) {
|
||||
const j = (typeof r.data === 'string') ? JSON.parse(r.data) : r.data;
|
||||
return !!validate(j);
|
||||
}
|
||||
}catch(_){}
|
||||
return false;
|
||||
};
|
||||
if (await tryJson(org + '/siteConfig.json', j => j && (j.appTitle || j.headerTitle || j.auth || j.oidc || j.basicAuth))) return {ok:true, origin:org};
|
||||
if (await tryJson(org + '/api/ping.php', j => j && (j.ok===true || j.status==='ok' || j.pong || j.app==='FileRise'))) return {ok:true, origin:org};
|
||||
if (await tryJson(org + '/api/version.php', j => j && (j.version || j.app==='FileRise'))) return {ok:true, origin:org};
|
||||
try{
|
||||
const r = await Http.get({ url: org+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
|
||||
if (typeof r.data === 'string' && /FileRise/i.test(r.data)) return {ok:true, origin:org};
|
||||
}catch(_){}
|
||||
return {ok:false, origin:org};
|
||||
}
|
||||
|
||||
async function probeReachable(u, timeout=3000){
|
||||
try{
|
||||
const base = new URL(normalize(u)).origin, ico=base+'/favicon.ico';
|
||||
if (Http){
|
||||
try{ const r=await Http.get({ url: ico, connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
|
||||
if (r && typeof r.status==='number' && r.status<500) return true; }catch(e){}
|
||||
try{ const r2=await Http.get({ url: base+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
|
||||
if (r2 && typeof r2.status==='number' && r2.status<500) return true; }catch(e){}
|
||||
return false;
|
||||
}
|
||||
return await new Promise(res=>{
|
||||
const img=new Image(), t=setTimeout(()=>done(false), timeout);
|
||||
function done(ok){ clearTimeout(t); img.onload=img.onerror=null; res(ok); }
|
||||
img.onload=()=>done(true); img.onerror=()=>done(false);
|
||||
img.src = ico + (ico.includes('?')?'&':'?') + '__fr=' + Date.now();
|
||||
});
|
||||
}catch{ return false; }
|
||||
}
|
||||
|
||||
async function loadInstances(){
|
||||
const raw=(await Pref.get({key:K_INST})).value;
|
||||
try { return raw ? JSON.parse(raw) : []; } catch { return []; }
|
||||
}
|
||||
async function saveInstances(list){
|
||||
await Pref.set({key:K_INST, value:JSON.stringify(list)});
|
||||
}
|
||||
async function getActive(){ return (await Pref.get({key:K_ACTIVE})).value }
|
||||
async function setActive(id){ await Pref.set({key:K_ACTIVE, value:id||''}) }
|
||||
|
||||
// ---- Styles (slide-up sheet + disabled buttons + safe-area) ----
|
||||
if (!$('#frx-mobile-style')) {
|
||||
const css = `
|
||||
.frx-fab { position:fixed; right:16px; bottom:calc(env(safe-area-inset-bottom,0px) + 18px); width:52px; height:52px; border-radius:26px;
|
||||
background: linear-gradient(180deg,#64B5F6,#2196F3 65%,#1976D2); color:#fff; display:grid; place-items:center;
|
||||
box-shadow:0 10px 22px rgba(33,150,243,.38); z-index:2147483647; cursor:pointer; user-select:none; }
|
||||
.frx-fab:active { transform: translateY(1px) scale(.98); }
|
||||
.frx-fab svg { width:26px; height:26px; fill:white }
|
||||
.frx-scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483645;opacity:0;visibility:hidden;transition:opacity .24s ease}
|
||||
.frx-scrim.show{opacity:1;visibility:visible}
|
||||
.frx-sheet{position:fixed;left:0;right:0;bottom:0;background:#0f172a;color:#e5e7eb;
|
||||
border-top-left-radius:16px;border-top-right-radius:16px;box-shadow:0 -10px 30px rgba(0,0,0,.3);
|
||||
z-index:2147483646;transform:translateY(100%);opacity:0;visibility:hidden;
|
||||
transition:transform .28s cubic-bezier(.2,.8,.2,1), opacity .28s ease; will-change:transform}
|
||||
.frx-sheet.show{transform:translateY(0);opacity:1;visibility:visible}
|
||||
.frx-sheet .hdr{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,.08)}
|
||||
.frx-title{display:flex;align-items:center;gap:10px;font-weight:800}
|
||||
.frx-title img{width:22px;height:22px}
|
||||
.frx-list{max-height:60vh;overflow:auto;padding:8px 12px}
|
||||
.frx-chip{border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:12px;margin:8px 4px;background:rgba(255,255,255,.04)}
|
||||
.frx-chip.active{outline:3px solid rgba(33,150,243,.35); border-color:#2196F3}
|
||||
.frx-top{display:flex;gap:10px;align-items:center;justify-content:space-between;margin-bottom:10px}
|
||||
.frx-left{display:flex;gap:10px;align-items:center}
|
||||
.frx-ico{width:20px;height:20px;border-radius:6px;overflow:hidden;background:#fff;display:grid;place-items:center}
|
||||
.frx-ico img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.frx-name{font-weight:800}
|
||||
.frx-host{font-size:12px;opacity:.8;margin-top:2px}
|
||||
.frx-status{display:flex;align-items:center;gap:6px;font-size:12px;opacity:.9}
|
||||
.frx-dot{width:10px;height:10px;border-radius:50%;}
|
||||
.frx-dot.on{background:#10B981;box-shadow:0 0 0 3px rgba(16,185,129,.18)}
|
||||
.frx-dot.off{background:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.18)}
|
||||
.frx-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.frx-btn{appearance:none;border:0;border-radius:10px;padding:10px 12px;font-weight:700;cursor:pointer;transition:.15s ease opacity, .15s ease filter}
|
||||
.frx-btn[disabled]{opacity:.5;cursor:not-allowed;filter:grayscale(20%)}
|
||||
.frx-primary{background:linear-gradient(180deg,#64B5F6,#2196F3);color:#fff}
|
||||
.frx-ghost{background:transparent;color:#cbd5e1;border:1px solid rgba(255,255,255,.12)}
|
||||
.frx-danger{background:transparent;color:#f44336;border:1px solid rgba(244,67,54,.45)}
|
||||
.frx-row{display:flex;gap:8px;align-items:center}
|
||||
.frx-field{display:grid;gap:6px;margin:8px 4px}
|
||||
.frx-input{width:100%;padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,.12);background:transparent;color:inherit}
|
||||
.frx-footer{display:flex;justify-content:flex-end;gap:8px;padding:10px 12px;border-top:1px solid rgba(255,255,255,.08)}
|
||||
@media (pointer:coarse) { .frx-fab { width:58px; height:58px; border-radius:29px; } }
|
||||
`;
|
||||
document.head.appendChild(el('style',{id:'frx-mobile-style'}, css));
|
||||
}
|
||||
|
||||
// ---- DOM skeleton (no innerHTML) ----
|
||||
const scrim = el('div',{class:'frx-scrim', id:'frx-scrim'});
|
||||
const sheet = el('div',{class:'frx-sheet', id:'frx-sheet'});
|
||||
const hdr = el('div',{class:'hdr'});
|
||||
const title = el('div',{class:'frx-title'});
|
||||
const logo = el('img',{src:'/assets/logo.svg', alt:'FileRise'});
|
||||
// inline handler via property, not attribute
|
||||
logo.onerror = function(){ this.style.display='none'; };
|
||||
title.append(logo, el('span',{},'FileRise Switcher'));
|
||||
const hdrBtns = el('div',{class:'frx-row'},[
|
||||
el('button',{class:'frx-btn frx-ghost', id:'frx-home'},'Home'),
|
||||
el('button',{class:'frx-btn frx-ghost', id:'frx-close'},'Close')
|
||||
]);
|
||||
hdr.append(title, hdrBtns);
|
||||
|
||||
const list = el('div',{class:'frx-list', id:'frx-list'});
|
||||
const formWrap = el('div',{style:'padding:10px 12px'},[
|
||||
el('div',{class:'frx-field'},[
|
||||
el('input',{class:'frx-input', id:'frx-name', placeholder:'Display name (optional)'}),
|
||||
el('input',{class:'frx-input', id:'frx-url', placeholder:'https://files.example.com'})
|
||||
])
|
||||
]);
|
||||
const footer = el('div',{class:'frx-footer'},[
|
||||
el('button',{class:'frx-btn frx-ghost', id:'frx-add-cancel'},'Close'),
|
||||
el('button',{class:'frx-btn frx-primary', id:'frx-add-save'},'+ Add server')
|
||||
]);
|
||||
sheet.append(hdr, list, formWrap, footer);
|
||||
|
||||
const fab = el('div',{class:'frx-fab', id:'frx-fab', title:'Switch server'},[
|
||||
el('svg',{viewBox:'0 0 24 24'},[ el('path',{d:'M7 7h10v2H7V7zm0 4h10v2H7v-2zm0 4h10v2H7v-2z'}) ])
|
||||
]);
|
||||
|
||||
document.body.appendChild(scrim);
|
||||
document.body.appendChild(sheet);
|
||||
document.body.appendChild(fab);
|
||||
|
||||
function show(){ scrim.classList.add('show'); sheet.classList.add('show'); fab.style.display='none'; }
|
||||
function hide(){ scrim.classList.remove('show'); sheet.classList.remove('show'); fab.style.display='grid'; }
|
||||
$('#frx-close').addEventListener('click', hide);
|
||||
$('#frx-add-cancel').addEventListener('click', hide);
|
||||
$('#frx-home').addEventListener('click', ()=>{ try{ location.href='capacitor://localhost/index.html'; }catch{} });
|
||||
scrim.addEventListener('click', hide);
|
||||
document.addEventListener('keydown', e=>{ if(e.key==='Escape') hide(); });
|
||||
|
||||
function chipNode(item, isActive){
|
||||
const hv = host(item.url);
|
||||
const node = el('div',{class:'frx-chip'+(isActive?' active':''), 'data-id':item.id});
|
||||
|
||||
const top = el('div',{class:'frx-top'});
|
||||
const left = el('div',{class:'frx-left'});
|
||||
|
||||
const ico = el('div',{class:'frx-ico'});
|
||||
const img = new Image();
|
||||
img.alt=''; img.src=item.favicon||faviconUrl(item.url)||initialsIcon(hv);
|
||||
img.onerror=()=>{ img.onerror=null; img.src=initialsIcon(hv); };
|
||||
ico.appendChild(img);
|
||||
|
||||
const txt = el('div',{},[
|
||||
el('div',{class:'frx-name'}, (item.name || hv)),
|
||||
el('div',{class:'frx-host'}, hv)
|
||||
]);
|
||||
|
||||
left.appendChild(ico);
|
||||
left.appendChild(txt);
|
||||
|
||||
const dot = el('span',{class:'frx-dot', id:`frx-dot-${item.id}`});
|
||||
const lbl = el('span',{id:`frx-lbl-${item.id}`}, 'Checking…');
|
||||
const status = el('div',{class:'frx-status'}, [dot, lbl]);
|
||||
|
||||
top.appendChild(left);
|
||||
top.appendChild(status);
|
||||
|
||||
const actions = el('div',{class:'frx-actions'});
|
||||
const bOpen = el('button',{class:'frx-btn frx-primary', 'data-act':'open', disabled:true}, 'Open');
|
||||
const bRen = el('button',{class:'frx-btn frx-ghost', 'data-act':'rename'}, 'Rename');
|
||||
const bDel = el('button',{class:'frx-btn frx-danger', 'data-act':'remove'}, 'Remove');
|
||||
actions.appendChild(bOpen); actions.appendChild(bRen); actions.appendChild(bDel);
|
||||
|
||||
node.appendChild(top);
|
||||
node.appendChild(actions);
|
||||
return node;
|
||||
}
|
||||
|
||||
async function renderList(){
|
||||
const listEl=$('#frx-list'); listEl.textContent='';
|
||||
const list=await loadInstances(); const active=await getActive();
|
||||
const cache=await getStatusCache();
|
||||
|
||||
list.sort((a,b)=>(b.lastUsed||0)-(a.lastUsed||0)).forEach(item=>{
|
||||
const chip = chipNode(item, item.id===active);
|
||||
const o = originOf(item.url), cached = cache[o];
|
||||
const dot = chip.querySelector(`#frx-dot-${item.id}`);
|
||||
const lbl = chip.querySelector(`#frx-lbl-${item.id}`);
|
||||
const openBtn = chip.querySelector('[data-act="open"]');
|
||||
|
||||
if (cached){
|
||||
dot.classList.add(cached.ok ? 'on':'off');
|
||||
lbl.textContent = cached.ok ? 'Online' : 'Offline';
|
||||
openBtn.disabled = !cached.ok;
|
||||
} else {
|
||||
lbl.textContent = 'Unknown';
|
||||
openBtn.disabled = true;
|
||||
}
|
||||
|
||||
chip.addEventListener('click', async (e)=>{
|
||||
const act = e.target?.dataset?.act;
|
||||
if (!act) return;
|
||||
|
||||
if (act==='open'){
|
||||
if (openBtn.disabled) return;
|
||||
await setActive(item.id);
|
||||
const dest = withParam(item.url, 'frapp', '1');
|
||||
if (dest) window.location.replace(dest);
|
||||
} else if (act==='rename'){
|
||||
const nn=prompt('New display name:', item.name || host(item.url));
|
||||
if (nn!=null){
|
||||
const L=await loadInstances(); const it=L.find(x=>x.id===item.id);
|
||||
if (it){ it.name=nn.trim().slice(0,120); it.lastUsed=Date.now(); await saveInstances(L); renderList(); }
|
||||
}
|
||||
} else if (act==='remove'){
|
||||
if (!confirm('Remove this server?')) return;
|
||||
let L=await loadInstances(); L=L.filter(x=>x.id!==item.id); await saveInstances(L);
|
||||
const a=await getActive(); if (a===item.id) await setActive(L[0]?.id||''); renderList();
|
||||
}
|
||||
});
|
||||
|
||||
listEl.appendChild(chip);
|
||||
|
||||
// Live refresh (best effort)
|
||||
(async ()=>{
|
||||
const ok = await probeReachable(item.url, 2500);
|
||||
const d = document.getElementById(`frx-dot-${item.id}`);
|
||||
const l = document.getElementById(`frx-lbl-${item.id}`);
|
||||
const b = chip.querySelector('[data-act="open"]');
|
||||
if (d && l && b){
|
||||
d.classList.remove('on','off');
|
||||
d.classList.add(ok?'on':'off');
|
||||
l.textContent = ok ? 'Online' : 'Offline';
|
||||
b.disabled = !ok;
|
||||
}
|
||||
const o2 = originOf(item.url); if (o2) writeStatus(o2, ok);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
$('#frx-add-save').addEventListener('click', async ()=>{
|
||||
const name = $('#frx-name').value.trim();
|
||||
const url = $('#frx-url').value.trim();
|
||||
if (!url) { alert('Enter a valid URL'); return; }
|
||||
|
||||
// Verify: must be FileRise
|
||||
const vf = await verifyFileRise(url);
|
||||
if (!vf.ok) { alert('That address does not look like a FileRise server.'); return; }
|
||||
|
||||
let L = await loadInstances();
|
||||
const h = host(url);
|
||||
const dupe = L.find(i => host(i.url)===h);
|
||||
const inst = dupe || { id:'i'+Math.random().toString(36).slice(2)+Date.now().toString(36) };
|
||||
inst.name = name || inst.name || h;
|
||||
inst.url = normalize(url);
|
||||
inst.favicon = faviconUrl(url);
|
||||
inst.lastUsed = Date.now();
|
||||
if (!dupe) L.push(inst);
|
||||
await saveInstances(L);
|
||||
await setActive(inst.id);
|
||||
|
||||
if (vf.origin) await writeStatus(vf.origin, true);
|
||||
|
||||
const dest = withParam(inst.url, 'frapp', '1');
|
||||
if (dest) window.location.replace(dest);
|
||||
});
|
||||
|
||||
fab.addEventListener('click', async ()=>{ await renderList(); show(); });
|
||||
|
||||
// Ensure zoom gestures work if the host page tried to disable them
|
||||
(function ensureZoomable(){
|
||||
let m = document.querySelector('meta[name=viewport]');
|
||||
const desired = 'width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=yes, minimum-scale=1, maximum-scale=5';
|
||||
if (!m){ m = document.createElement('meta'); m.setAttribute('name','viewport'); document.head.appendChild(m); }
|
||||
const c = m.getAttribute('content') || '';
|
||||
if (/user-scalable=no|maximum-scale=1/.test(c)) m.setAttribute('content', desired);
|
||||
})();
|
||||
})();
|
||||
5
public/js/pwa/register-sw.js
Normal file
@@ -0,0 +1,5 @@
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js?v={{APP_QVER}}').catch(() => {});
|
||||
});
|
||||
}
|
||||
9
public/js/pwa/sw.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// public/js/pwa/sw.js
|
||||
const SW_VERSION = '{{APP_QVER}}';
|
||||
const STATIC_CACHE = `fr-static-${SW_VERSION}`;
|
||||
const STATIC_ASSETS = [
|
||||
'/', '/index.html',
|
||||
'/css/styles.css?v={{APP_QVER}}',
|
||||
'/js/main.js?v={{APP_QVER}}',
|
||||
'/assets/logo.svg?v={{APP_QVER}}'
|
||||
];
|
||||
@@ -2,7 +2,7 @@
|
||||
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree, refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
function showConfirm(message, onConfirm) {
|
||||
@@ -89,6 +89,7 @@ export function setupTrashRestoreDelete() {
|
||||
toggleVisibility("restoreFilesModal", false);
|
||||
loadFileList(window.currentFolder);
|
||||
loadFolderTree(window.currentFolder);
|
||||
refreshFolderIcon(window.currentFolder);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error restoring files:", err);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
|
||||
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
/* -----------------------------------------------------
|
||||
@@ -588,7 +589,7 @@ async function initResumableUpload() {
|
||||
if (removeBtn) removeBtn.style.display = "none";
|
||||
setTimeout(() => li.remove(), 5000);
|
||||
}
|
||||
|
||||
refreshFolderIcon(window.currentFolder);
|
||||
loadFileList(window.currentFolder);
|
||||
});
|
||||
|
||||
@@ -895,7 +896,8 @@ function initUpload() {
|
||||
dropArea.addEventListener("drop", function (e) {
|
||||
e.preventDefault();
|
||||
dropArea.style.backgroundColor = "";
|
||||
const dt = e.dataTransfer;
|
||||
const dt = e.dataTransfer || window.__pendingDropData || null;
|
||||
window.__pendingDropData = null;
|
||||
if (dt.items && dt.items.length > 0) {
|
||||
getFilesFromDataTransferItems(dt.items).then(files => {
|
||||
if (files.length > 0) {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v1.7.4';
|
||||
window.APP_VERSION = 'v1.9.2';
|
||||
14
public/manifest.webmanifest
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "FileRise",
|
||||
"short_name": "FileRise",
|
||||
"start_url": "/?pwa=1",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111111",
|
||||
"theme_color": "#0b5ed7",
|
||||
"icons": [
|
||||
{ "src": "/assets/icons/icon-192.png?v={{APP_QVER}}", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/assets/icons/icon-512.png?v={{APP_QVER}}", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/assets/icons/maskable-512.png?v={{APP_QVER}}", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
6
public/sw.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Root-scoped stub. Keeps the worker’s scope at “/” level
|
||||
try {
|
||||
self.importScripts('/js/pwa/sw.js?v={{APP_QVER}}');
|
||||
} catch (_) {
|
||||
// no-op
|
||||
}
|
||||
|
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: 645 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 687 KiB After Width: | Height: | Size: 694 KiB |
BIN
resources/filerise-v1.9.0.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 552 KiB After Width: | Height: | Size: 546 KiB |
BIN
resources/light-color-folder.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 754 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: 541 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 |
54
scripts/manual-sync.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# === Update FileRise to v1.9.1 (safe rsync) ===
|
||||
# shellcheck disable=SC2155 # we intentionally assign 'stamp' with command substitution
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
VER="v1.9.1"
|
||||
ASSET="FileRise-${VER}.zip" # If the asset name is different, set it exactly (e.g. FileRise-v1.9.0.zip)
|
||||
WEBROOT="/var/www"
|
||||
TMP="/tmp/filerise-update"
|
||||
|
||||
# 0) (optional) quick backup of critical bits
|
||||
stamp="$(date +%F-%H%M)"
|
||||
mkdir -p /root/backups
|
||||
tar -C "$WEBROOT" -czf "/root/backups/filerise-$stamp.tgz" \
|
||||
public/.htaccess config users uploads metadata || true
|
||||
echo "Backup saved to /root/backups/filerise-$stamp.tgz"
|
||||
|
||||
# 1) Fetch the release zip
|
||||
rm -rf "$TMP"
|
||||
mkdir -p "$TMP"
|
||||
curl -fsSL "https://github.com/error311/FileRise/releases/download/${VER}/${ASSET}" -o "$TMP/$ASSET"
|
||||
|
||||
# 2) Unzip to a staging dir
|
||||
unzip -q "$TMP/$ASSET" -d "$TMP"
|
||||
STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" | head -n1 || true)"
|
||||
[ -n "${STAGE_DIR:-}" ] || STAGE_DIR="$TMP"
|
||||
|
||||
# 3) Sync code into /var/www
|
||||
# - keep public/.htaccess
|
||||
# - keep data dirs and current config.php
|
||||
rsync -a --delete \
|
||||
--exclude='public/.htaccess' \
|
||||
--exclude='uploads/***' \
|
||||
--exclude='users/***' \
|
||||
--exclude='metadata/***' \
|
||||
--exclude='config/config.php' \
|
||||
--exclude='.github/***' \
|
||||
--exclude='docker-compose.yml' \
|
||||
"$STAGE_DIR"/ "$WEBROOT"/
|
||||
|
||||
# 4) Ownership (Ubuntu/Debian w/ Apache)
|
||||
chown -R www-data:www-data "$WEBROOT"
|
||||
|
||||
# 5) (optional) Composer autoload optimization if composer is available
|
||||
if command -v composer >/dev/null 2>&1; then
|
||||
cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
|
||||
composer install --no-dev --optimize-autoloader
|
||||
fi
|
||||
|
||||
# 6) Reload Apache (don’t fail the whole script if reload isn’t available)
|
||||
systemctl reload apache2 2>/dev/null || true
|
||||
|
||||
echo "✅ FileRise updated to ${VER} (code). Data and public/.htaccess preserved."
|
||||
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);
|
||||
}
|
||||
@@ -5,11 +5,11 @@ require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
||||
|
||||
class AdminController
|
||||
{
|
||||
{
|
||||
public function getConfig(): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
|
||||
$config = AdminModel::getConfig();
|
||||
if (isset($config['error'])) {
|
||||
http_response_code(500);
|
||||
@@ -17,8 +17,24 @@ class AdminController
|
||||
echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return;
|
||||
}
|
||||
|
||||
// Whitelisted public subset only
|
||||
|
||||
// ---- Effective ONLYOFFICE values (constants override adminConfig) ----
|
||||
$ooCfg = is_array($config['onlyoffice'] ?? null) ? $config['onlyoffice'] : [];
|
||||
$effEnabled = defined('ONLYOFFICE_ENABLED')
|
||||
? (bool) ONLYOFFICE_ENABLED
|
||||
: (bool) ($ooCfg['enabled'] ?? false);
|
||||
|
||||
$effDocs = defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== ''
|
||||
? (string) ONLYOFFICE_DOCS_ORIGIN
|
||||
: (string) ($ooCfg['docsOrigin'] ?? '');
|
||||
|
||||
$hasSecret = defined('ONLYOFFICE_JWT_SECRET')
|
||||
? (ONLYOFFICE_JWT_SECRET !== '')
|
||||
: (!empty($ooCfg['jwtSecret']));
|
||||
|
||||
$publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? '');
|
||||
|
||||
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
|
||||
$public = [
|
||||
'header_title' => (string)($config['header_title'] ?? 'FileRise'),
|
||||
'loginOptions' => [
|
||||
@@ -34,12 +50,16 @@ class AdminController
|
||||
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||
// never include clientId/clientSecret
|
||||
],
|
||||
'onlyoffice' => [
|
||||
// Public only needs to know if it’s on; no secrets/origins here.
|
||||
'enabled' => $effEnabled,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
|
||||
|
||||
|
||||
if ($isAdmin) {
|
||||
// admin-only extras: presence flags + proxy options
|
||||
// admin-only extras: presence flags + proxy options + ONLYOFFICE effective view
|
||||
$adminExtra = [
|
||||
'loginOptions' => array_merge($public['loginOptions'], [
|
||||
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
|
||||
@@ -49,12 +69,23 @@ class AdminController
|
||||
'hasClientId' => !empty($config['oidc']['clientId']),
|
||||
'hasClientSecret' => !empty($config['oidc']['clientSecret']),
|
||||
]),
|
||||
'onlyoffice' => [
|
||||
'enabled' => $effEnabled,
|
||||
'docsOrigin' => $effDocs, // effective (constants win)
|
||||
'publicOrigin' => $publicOriginCfg, // optional override from adminConfig
|
||||
'hasJwtSecret' => (bool)$hasSecret, // boolean only; never leak secret
|
||||
'lockedByPhp' => (
|
||||
defined('ONLYOFFICE_ENABLED')
|
||||
|| defined('ONLYOFFICE_DOCS_ORIGIN')
|
||||
|| defined('ONLYOFFICE_JWT_SECRET')
|
||||
),
|
||||
],
|
||||
];
|
||||
header('Cache-Control: no-store'); // don’t cache admin config
|
||||
echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Non-admins / unauthenticated: only the public subset
|
||||
header('Cache-Control: no-store');
|
||||
echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
@@ -221,6 +252,40 @@ class AdminController
|
||||
}
|
||||
|
||||
// —– persist merged config —–
|
||||
// ---- ONLYOFFICE: merge from payload (unless locked by PHP defines) ----
|
||||
$ooLockedByPhp = (
|
||||
defined('ONLYOFFICE_ENABLED') ||
|
||||
defined('ONLYOFFICE_DOCS_ORIGIN') ||
|
||||
defined('ONLYOFFICE_JWT_SECRET') ||
|
||||
defined('ONLYOFFICE_PUBLIC_ORIGIN')
|
||||
);
|
||||
|
||||
if (!$ooLockedByPhp && isset($data['onlyoffice']) && is_array($data['onlyoffice'])) {
|
||||
$ooExisting = (isset($existing['onlyoffice']) && is_array($existing['onlyoffice']))
|
||||
? $existing['onlyoffice'] : [];
|
||||
|
||||
$oo = $ooExisting;
|
||||
|
||||
if (array_key_exists('enabled', $data['onlyoffice'])) {
|
||||
$oo['enabled'] = filter_var($data['onlyoffice']['enabled'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
if (isset($data['onlyoffice']['docsOrigin'])) {
|
||||
$oo['docsOrigin'] = (string)$data['onlyoffice']['docsOrigin'];
|
||||
}
|
||||
if (isset($data['onlyoffice']['publicOrigin'])) {
|
||||
$oo['publicOrigin'] = (string)$data['onlyoffice']['publicOrigin'];
|
||||
}
|
||||
// Allow setting/changing the secret when NOT locked by PHP
|
||||
if (isset($data['onlyoffice']['jwtSecret'])) {
|
||||
$js = trim((string)$data['onlyoffice']['jwtSecret']);
|
||||
if ($js !== '') {
|
||||
$oo['jwtSecret'] = $js; // stored encrypted by AdminModel
|
||||
}
|
||||
// If blank, we leave existing secret unchanged (no implicit wipe).
|
||||
}
|
||||
|
||||
$merged['onlyoffice'] = $oo;
|
||||
}
|
||||
$result = AdminModel::updateConfig($merged);
|
||||
if (isset($result['error'])) {
|
||||
http_response_code(500);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
135
src/controllers/MediaController.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
// src/controllers/MediaController.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PROJECT_ROOT . '/config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/MediaModel.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
|
||||
class MediaController
|
||||
{
|
||||
private function jsonStart(): void {
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
set_error_handler(function ($severity, $message, $file, $line) {
|
||||
if (!(error_reporting() & $severity)) return;
|
||||
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
}
|
||||
private function jsonEnd(): void { restore_error_handler(); }
|
||||
private function out($payload, int $status=200): void {
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
private function readJson(): array {
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
private function requireAuth(): ?string {
|
||||
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
$this->out(['error'=>'Unauthorized'], 401); return 'no';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private function checkCsrf(): ?string {
|
||||
$headers = function_exists('getallheaders') ? array_change_key_case(getallheaders(), CASE_LOWER) : [];
|
||||
$received = $headers['x-csrf-token'] ?? '';
|
||||
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
|
||||
$this->out(['error'=>'Invalid CSRF token'], 403); return 'no';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private function normalizeFolder($f): string {
|
||||
$f = trim((string)$f);
|
||||
return ($f==='' || strtolower($f)==='root') ? 'root' : $f;
|
||||
}
|
||||
private function validFolder($f): bool {
|
||||
return $f==='root' || (bool)preg_match(REGEX_FOLDER_NAME, $f);
|
||||
}
|
||||
private function validFile($f): bool {
|
||||
$f = basename((string)$f);
|
||||
return $f !== '' && (bool)preg_match(REGEX_FILE_NAME, $f);
|
||||
}
|
||||
private function enforceRead(string $folder, string $username): ?string {
|
||||
$perms = loadUserPermissions($username) ?: [];
|
||||
return ACL::canRead($username, $perms, $folder) ? null : "Forbidden";
|
||||
}
|
||||
|
||||
/** POST /api/media/updateProgress.php */
|
||||
public function updateProgress(): void {
|
||||
$this->jsonStart();
|
||||
try {
|
||||
if ($this->requireAuth()) return;
|
||||
if ($this->checkCsrf()) return;
|
||||
|
||||
$u = $_SESSION['username'] ?? '';
|
||||
$d = $this->readJson();
|
||||
$folder = $this->normalizeFolder($d['folder'] ?? 'root');
|
||||
$file = (string)($d['file'] ?? '');
|
||||
$seconds = isset($d['seconds']) ? floatval($d['seconds']) : 0.0;
|
||||
$duration = isset($d['duration']) ? floatval($d['duration']) : null;
|
||||
$completed = isset($d['completed']) ? (bool)$d['completed'] : null;
|
||||
$clear = isset($d['clear']) ? (bool)$d['clear'] : false;
|
||||
|
||||
if (!$this->validFolder($folder) || !$this->validFile($file)) {
|
||||
$this->out(['error'=>'Invalid folder/file'], 400); return;
|
||||
}
|
||||
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
|
||||
|
||||
if ($clear) {
|
||||
$ok = MediaModel::clearProgress($u, $folder, $file);
|
||||
$this->out(['success'=>$ok]); return;
|
||||
}
|
||||
|
||||
$row = MediaModel::saveProgress($u, $folder, $file, $seconds, $duration, $completed);
|
||||
$this->out(['success'=>true, 'state'=>$row]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('MediaController::updateProgress: '.$e->getMessage());
|
||||
$this->out(['error'=>'Internal server error'], 500);
|
||||
} finally { $this->jsonEnd(); }
|
||||
}
|
||||
|
||||
/** GET /api/media/getProgress.php?folder=…&file=… */
|
||||
public function getProgress(): void {
|
||||
$this->jsonStart();
|
||||
try {
|
||||
if ($this->requireAuth()) return;
|
||||
$u = $_SESSION['username'] ?? '';
|
||||
$folder = $this->normalizeFolder($_GET['folder'] ?? 'root');
|
||||
$file = (string)($_GET['file'] ?? '');
|
||||
|
||||
if (!$this->validFolder($folder) || !$this->validFile($file)) {
|
||||
$this->out(['error'=>'Invalid folder/file'], 400); return;
|
||||
}
|
||||
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
|
||||
|
||||
$row = MediaModel::getProgress($u, $folder, $file);
|
||||
$this->out(['state'=>$row]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('MediaController::getProgress: '.$e->getMessage());
|
||||
$this->out(['error'=>'Internal server error'], 500);
|
||||
} finally { $this->jsonEnd(); }
|
||||
}
|
||||
|
||||
/** GET /api/media/getViewedMap.php?folder=… (optional, for badges) */
|
||||
public function getViewedMap(): void {
|
||||
$this->jsonStart();
|
||||
try {
|
||||
if ($this->requireAuth()) return;
|
||||
$u = $_SESSION['username'] ?? '';
|
||||
$folder = $this->normalizeFolder($_GET['folder'] ?? 'root');
|
||||
|
||||
if (!$this->validFolder($folder)) {
|
||||
$this->out(['error'=>'Invalid folder'], 400); return;
|
||||
}
|
||||
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
|
||||
|
||||
$map = MediaModel::getFolderMap($u, $folder);
|
||||
$this->out(['map'=>$map]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('MediaController::getViewedMap: '.$e->getMessage());
|
||||
$this->out(['error'=>'Internal server error'], 500);
|
||||
} finally { $this->jsonEnd(); }
|
||||
}
|
||||
}
|
||||
413
src/controllers/OnlyOfficeController.php
Normal file
@@ -0,0 +1,413 @@
|
||||
<?php
|
||||
// src/controllers/OnlyOfficeController.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
|
||||
class OnlyOfficeController
|
||||
{
|
||||
|
||||
|
||||
// What FileRise will route to ONLYOFFICE at all (edit *or* view)
|
||||
private const OO_SUPPORTED_EXTS = [
|
||||
'doc','docx','odt','rtf','txt',
|
||||
'xls','xlsx','ods','csv',
|
||||
'ppt','pptx','odp',
|
||||
'pdf'
|
||||
];
|
||||
|
||||
/** 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'];
|
||||
|
||||
// (Optional) More view-only types you can enable if you like
|
||||
private const OO_VIEW_ONLY_EXTRAS = [
|
||||
'djvu','xps','oxps','epub','fb2','pages','hwp','hwpx',
|
||||
'vsdx','vsdm','vssx','vssm','vstx','vstm'
|
||||
];
|
||||
/** Resolve effective secret: constants override adminConfig */
|
||||
private function effectiveSecret(): string
|
||||
{
|
||||
$cfg = AdminModel::getConfig();
|
||||
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
|
||||
if (defined('ONLYOFFICE_JWT_SECRET') && ONLYOFFICE_JWT_SECRET !== '') {
|
||||
return (string)ONLYOFFICE_JWT_SECRET;
|
||||
}
|
||||
return (string)($oo['jwtSecret'] ?? '');
|
||||
}
|
||||
|
||||
// --- lightweight logger ------------------------------------------------------
|
||||
private const OO_LOG_PATH = '/var/www/users/onlyoffice-cb.debug';
|
||||
|
||||
private function ooDebug(): bool
|
||||
{
|
||||
// Enable verbose logging by either constant or env var
|
||||
if (defined('ONLYOFFICE_DEBUG') && ONLYOFFICE_DEBUG) return true;
|
||||
return getenv('ONLYOFFICE_DEBUG') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param 'error'|'warn'|'info'|'debug' $level
|
||||
*/
|
||||
private function ooLog(string $level, string $msg): void
|
||||
{
|
||||
$level = strtolower($level);
|
||||
$line = '[OO-CB][' . strtoupper($level) . '] ' . $msg;
|
||||
|
||||
// Only emit to Apache on errors (keeps logs clean)
|
||||
if ($level === 'error') {
|
||||
error_log($line);
|
||||
}
|
||||
|
||||
// If debug mode is on, mirror all levels to a local file
|
||||
if ($this->ooDebug()) {
|
||||
@file_put_contents(self::OO_LOG_PATH, '[' . date('c') . '] ' . $line . "\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve effective docs origin (http/https root of OO Docs server) */
|
||||
private function effectiveDocsOrigin(): string
|
||||
{
|
||||
$cfg = AdminModel::getConfig();
|
||||
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
|
||||
if (defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== '') {
|
||||
return (string)ONLYOFFICE_DOCS_ORIGIN;
|
||||
}
|
||||
if (!empty($oo['docsOrigin'])) return (string)$oo['docsOrigin'];
|
||||
$env = getenv('ONLYOFFICE_DOCS_ORIGIN');
|
||||
return $env ? (string)$env : '';
|
||||
}
|
||||
|
||||
/** Resolve effective enabled flag (constants override adminConfig) */
|
||||
private function effectiveEnabled(): bool
|
||||
{
|
||||
$cfg = AdminModel::getConfig();
|
||||
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
|
||||
if (defined('ONLYOFFICE_ENABLED')) return (bool)ONLYOFFICE_ENABLED;
|
||||
return !empty($oo['enabled']);
|
||||
}
|
||||
|
||||
/** Optional explicit public origin; else infer from BASE_URL / request */
|
||||
private function effectivePublicOrigin(): string
|
||||
{
|
||||
$cfg = AdminModel::getConfig();
|
||||
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
|
||||
|
||||
if (defined('ONLYOFFICE_PUBLIC_ORIGIN') && ONLYOFFICE_PUBLIC_ORIGIN !== '') {
|
||||
return (string)ONLYOFFICE_PUBLIC_ORIGIN;
|
||||
}
|
||||
if (!empty($oo['publicOrigin'])) return (string)$oo['publicOrigin'];
|
||||
|
||||
// Try BASE_URL if it isn't a placeholder
|
||||
if (defined('BASE_URL') && strpos((string)BASE_URL, 'yourwebsite') === false) {
|
||||
$u = parse_url((string)BASE_URL);
|
||||
if (!empty($u['scheme']) && !empty($u['host'])) {
|
||||
return $u['scheme'].'://'.$u['host'].(isset($u['port'])?':'.$u['port']:'');
|
||||
}
|
||||
}
|
||||
// Fallback to request (proxy aware)
|
||||
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO']
|
||||
?? ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http');
|
||||
$host = $_SERVER['HTTP_X_FORWARDED_HOST'] ?? ($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||
return $proto.'://'.$host;
|
||||
}
|
||||
|
||||
/** base64url encode/decode helpers */
|
||||
private function b64uDec(string $s)
|
||||
{
|
||||
$s = strtr($s, '-_', '+/');
|
||||
$pad = strlen($s) % 4;
|
||||
if ($pad) $s .= str_repeat('=', 4 - $pad);
|
||||
return base64_decode($s, true);
|
||||
}
|
||||
private function b64uEnc(string $s): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($s), '+/','-_'), '=');
|
||||
}
|
||||
|
||||
/** GET /api/onlyoffice/status.php */
|
||||
public function status(): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$enabled = $this->effectiveEnabled();
|
||||
$docsOrig = $this->effectiveDocsOrigin();
|
||||
$secret = $this->effectiveSecret();
|
||||
|
||||
// Must have docs origin and secret to actually function
|
||||
$enabled = $enabled && ($docsOrig !== '') && ($secret !== '');
|
||||
|
||||
$exts = self::OO_SUPPORTED_EXTS;
|
||||
$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=... */
|
||||
// --- 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);
|
||||
|
||||
$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; }
|
||||
|
||||
$folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root'));
|
||||
$file = basename((string)($_GET['file'] ?? ''));
|
||||
if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; }
|
||||
|
||||
if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; }
|
||||
$canEdit = \ACL::canEdit($user, $perms, $folder);
|
||||
|
||||
$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; }
|
||||
|
||||
// IMPORTANT: use the internal/fast origin for DocServer fetch + callback
|
||||
$fileOriginForDocs = rtrim($this->effectiveFileOriginForDocs(), '/');
|
||||
|
||||
$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);
|
||||
|
||||
$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;
|
||||
|
||||
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx');
|
||||
$docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell'
|
||||
: (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word');
|
||||
$key = substr(sha1($abs . '|' . (string)filemtime($abs)), 0, 20);
|
||||
|
||||
$docsApiJs = $docsOrigin . '/web-apps/apps/api/documents/api.js';
|
||||
|
||||
$cfgOut = [
|
||||
'document' => [
|
||||
'fileType' => $ext,
|
||||
'key' => $key,
|
||||
'title' => $file,
|
||||
'url' => $fileUrl,
|
||||
'permissions' => [
|
||||
'download' => true,
|
||||
'print' => true,
|
||||
'edit' => $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true),
|
||||
],
|
||||
],
|
||||
'documentType' => $docType,
|
||||
'editorConfig' => [
|
||||
'callbackUrl' => $callbackUrl,
|
||||
'user' => ['id'=>$user, 'name'=>$user],
|
||||
'lang' => 'en',
|
||||
],
|
||||
'type' => 'desktop',
|
||||
];
|
||||
|
||||
// JWT sign cfg
|
||||
$h = $this->b64uEnc(json_encode(['alg'=>'HS256','typ'=>'JWT']));
|
||||
$p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES));
|
||||
$s = $this->b64uEnc(hash_hmac('sha256', "$h.$p", $secret, true));
|
||||
$cfgOut['token'] = "$h.$p.$s";
|
||||
|
||||
// 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
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (isset($_GET['ping'])) { echo '{"error":0}'; return; }
|
||||
|
||||
$secret = $this->effectiveSecret();
|
||||
if ($secret === '') { http_response_code(500); $this->ooLog('error', 'missing secret'); echo '{"error":6}'; return; }
|
||||
|
||||
$folderRaw = (string)($_GET['folder'] ?? 'root');
|
||||
$fileRaw = (string)($_GET['file'] ?? '');
|
||||
$exp = (int)($_GET['exp'] ?? 0);
|
||||
$sig = (string)($_GET['sig'] ?? '');
|
||||
$calc = hash_hmac('sha256', "$folderRaw|$fileRaw|$exp", $secret);
|
||||
|
||||
// Debug-only preflight (no secrets; show short sigs)
|
||||
if ($this->ooDebug()) {
|
||||
$this->ooLog('debug', sprintf(
|
||||
"PRE f='%s' n='%s' exp=%d sig[8]=%s calc[8]=%s",
|
||||
$folderRaw, $fileRaw, $exp, substr($sig, 0, 8), substr($calc, 0, 8)
|
||||
));
|
||||
}
|
||||
|
||||
$folder = \ACL::normalizeFolder($folderRaw);
|
||||
$file = basename($fileRaw);
|
||||
if (!$exp || time() > $exp) { $this->ooLog('error', "expired exp for $folder/$file"); echo '{"error":6}'; return; }
|
||||
if (!hash_equals($calc, $sig)) { $this->ooLog('error', "sig mismatch for $folder/$file"); echo '{"error":6}'; return; }
|
||||
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
if ($this->ooDebug()) {
|
||||
$this->ooLog('debug', 'BODY len=' . strlen($raw));
|
||||
}
|
||||
|
||||
$body = json_decode($raw, true) ?: [];
|
||||
$status = (int)($body['status'] ?? 0);
|
||||
$actor = (string)($body['actions'][0]['userid'] ?? '');
|
||||
|
||||
$actorIsAdmin = (defined('DEFAULT_ADMIN_USER') && $actor !== '' && strcasecmp($actor, (string)DEFAULT_ADMIN_USER) === 0)
|
||||
|| (strcasecmp($actor, 'admin') === 0);
|
||||
$perms = $actorIsAdmin ? ['admin'=>true] : [];
|
||||
|
||||
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
||||
$rel = ($folder === 'root') ? '' : ($folder . '/');
|
||||
$dir = realpath($base . $rel) ?: ($base . $rel);
|
||||
if (strpos($dir, realpath($base)) !== 0) { $this->ooLog('error', 'path escape'); echo '{"error":6}'; return; }
|
||||
|
||||
// Save-on statuses: 2/6/7
|
||||
if (in_array($status, [2,6,7], true)) {
|
||||
if (!$actor || !\ACL::canEdit($actor, $perms, $folder)) {
|
||||
$this->ooLog('error', "ACL deny edit: actor='$actor' folder='$folder'");
|
||||
echo '{"error":6}'; return;
|
||||
}
|
||||
$saveUrl = (string)($body['url'] ?? '');
|
||||
if ($saveUrl === '') { $this->ooLog('error', "no url for status=$status"); echo '{"error":6}'; return; }
|
||||
|
||||
// fetch saved file
|
||||
$data = null; $curlErr=''; $httpCode=0;
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($saveUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => 45,
|
||||
CURLOPT_HTTPHEADER => ['Accept: */*','User-Agent: FileRise-ONLYOFFICE-Callback'],
|
||||
]);
|
||||
$data = curl_exec($ch);
|
||||
if ($data === false) $curlErr = curl_error($ch);
|
||||
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($data === false || $httpCode >= 400) {
|
||||
$this->ooLog('error', "curl get failed ($httpCode) url=$saveUrl err=" . ($curlErr ?: 'n/a'));
|
||||
$data = null;
|
||||
}
|
||||
}
|
||||
if ($data === null) {
|
||||
$ctx = stream_context_create(['http'=>['method'=>'GET','timeout'=>45,'header'=>"Accept: */*\r\n"]]);
|
||||
$data = @file_get_contents($saveUrl, false, $ctx);
|
||||
if ($data === false) { $this->ooLog('error', "stream get failed url=$saveUrl"); echo '{"error":6}'; return; }
|
||||
}
|
||||
|
||||
if (!is_dir($dir)) { @mkdir($dir, 0775, true); }
|
||||
$dest = rtrim($dir, "/\\") . DIRECTORY_SEPARATOR . $file;
|
||||
if (@file_put_contents($dest, $data) === false) { $this->ooLog('error', "write failed: $dest"); echo '{"error":6}'; return; }
|
||||
|
||||
@touch($dest);
|
||||
|
||||
// Success: debug only
|
||||
if ($this->ooDebug()) {
|
||||
$this->ooLog('debug', "saved OK by '$actor' → $dest (" . strlen($data) . " bytes, status=$status)");
|
||||
}
|
||||
echo '{"error":0}'; return;
|
||||
}
|
||||
|
||||
// Non-saving statuses: debug only
|
||||
if ($this->ooDebug()) {
|
||||
$this->ooLog('debug', "status=$status ack for $folder/$file by '$actor'");
|
||||
}
|
||||
echo '{"error":0}';
|
||||
}
|
||||
|
||||
/** GET /api/onlyoffice/signed-download.php?tok=... */
|
||||
public function signedDownload(): void
|
||||
{
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$secret = $this->effectiveSecret();
|
||||
if ($secret === '') { http_response_code(403); return; }
|
||||
|
||||
$tok = $_GET['tok'] ?? '';
|
||||
if (!$tok || strpos($tok, '.') === false) { http_response_code(400); return; }
|
||||
[$b64data, $b64sig] = explode('.', $tok, 2);
|
||||
$data = $this->b64uDec($b64data);
|
||||
$sig = $this->b64uDec($b64sig);
|
||||
if ($data === false || $sig === false) { http_response_code(400); return; }
|
||||
|
||||
$calc = hash_hmac('sha256', $data, $secret, true);
|
||||
if (!hash_equals($calc, $sig)) { http_response_code(403); return; }
|
||||
|
||||
$payload = json_decode($data, true);
|
||||
if (!$payload || !isset($payload['f'],$payload['n'],$payload['exp'])) { http_response_code(400); return; }
|
||||
if (time() > (int)$payload['exp']) { http_response_code(403); return; }
|
||||
|
||||
$folder = trim(str_replace('\\','/',$payload['f']),"/ \t\r\n");
|
||||
if ($folder === '' || $folder === 'root') $folder = 'root';
|
||||
$file = basename((string)$payload['n']);
|
||||
|
||||
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
||||
$rel = ($folder === 'root') ? '' : ($folder . '/');
|
||||
$abs = realpath($base . $rel . $file);
|
||||
if (!$abs || !is_file($abs)) { http_response_code(404); return; }
|
||||
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); return; }
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
390
src/lib/ACL.php
@@ -10,23 +10,38 @@ class ACL
|
||||
private static $path = null;
|
||||
|
||||
private const BUCKETS = [
|
||||
'owners','read','write','share','read_own',
|
||||
'create','upload','edit','rename','copy','move','delete','extract',
|
||||
'share_file','share_folder'
|
||||
'owners',
|
||||
'read',
|
||||
'write',
|
||||
'share',
|
||||
'read_own',
|
||||
'create',
|
||||
'upload',
|
||||
'edit',
|
||||
'rename',
|
||||
'copy',
|
||||
'move',
|
||||
'delete',
|
||||
'extract',
|
||||
'share_file',
|
||||
'share_folder'
|
||||
];
|
||||
|
||||
private static function path(): string {
|
||||
private static function path(): string
|
||||
{
|
||||
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||
return self::$path;
|
||||
}
|
||||
|
||||
public static function normalizeFolder(string $f): string {
|
||||
public static function normalizeFolder(string $f): string
|
||||
{
|
||||
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
|
||||
if ($f === '' || $f === 'root') return 'root';
|
||||
return $f;
|
||||
}
|
||||
|
||||
public static function purgeUser(string $user): bool {
|
||||
public static function purgeUser(string $user): bool
|
||||
{
|
||||
$user = (string)$user;
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$changed = false;
|
||||
@@ -41,49 +56,107 @@ class ACL
|
||||
return $changed ? self::save($acl) : true;
|
||||
}
|
||||
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
if (self::hasGrant($user, $folder, 'owners')) return true;
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
if (self::hasGrant($user, $folder, 'owners')) return true;
|
||||
|
||||
$folder = trim($folder, "/\\ ");
|
||||
if ($folder === '' || $folder === 'root') return false;
|
||||
$folder = trim($folder, "/\\ ");
|
||||
if ($folder === '' || $folder === 'root') return false;
|
||||
|
||||
$parts = explode('/', $folder);
|
||||
while (count($parts) > 1) {
|
||||
array_pop($parts);
|
||||
$parent = implode('/', $parts);
|
||||
if (self::hasGrant($user, $parent, 'owners')) return true;
|
||||
$parts = explode('/', $folder);
|
||||
while (count($parts) > 1) {
|
||||
array_pop($parts);
|
||||
$parent = implode('/', $parts);
|
||||
if (self::hasGrant($user, $parent, 'owners')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function migrateSubtree(string $source, string $target): array
|
||||
{
|
||||
// PHP <8 polyfill
|
||||
if (!function_exists('str_starts_with')) {
|
||||
function str_starts_with(string $h, string $n): bool
|
||||
{
|
||||
return $n === '' || strncmp($h, $n, strlen($n)) === 0;
|
||||
}
|
||||
}
|
||||
|
||||
$src = self::normalizeFolder($source);
|
||||
$dst = self::normalizeFolder($target);
|
||||
if ($src === 'root') return ['changed' => false, 'moved' => 0];
|
||||
|
||||
$file = self::path(); // e.g. META_DIR/folder_acl.json
|
||||
$raw = @file_get_contents($file);
|
||||
$map = is_string($raw) ? json_decode($raw, true) : [];
|
||||
if (!is_array($map)) $map = [];
|
||||
|
||||
$prefix = $src;
|
||||
$needle = $src . '/';
|
||||
|
||||
$new = $map;
|
||||
$changed = false;
|
||||
$moved = 0;
|
||||
|
||||
foreach ($map as $key => $entry) {
|
||||
$isMatch = ($key === $prefix) || str_starts_with($key . '/', $needle);
|
||||
if (!$isMatch) continue;
|
||||
|
||||
unset($new[$key]);
|
||||
|
||||
$suffix = substr($key, strlen($prefix)); // '' or '/sub/...'
|
||||
$newKey = ($dst === 'root') ? ltrim($suffix, '/\\') : rtrim($dst, '/\\') . $suffix;
|
||||
|
||||
// keep only known buckets (defensive)
|
||||
if (is_array($entry)) {
|
||||
$clean = [];
|
||||
foreach (self::BUCKETS as $b) if (array_key_exists($b, $entry)) $clean[$b] = $entry[$b];
|
||||
$entry = $clean ?: $entry;
|
||||
}
|
||||
|
||||
// overwrite any existing entry at destination path (safer than union)
|
||||
$new[$newKey] = $entry;
|
||||
$changed = true;
|
||||
$moved++;
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
@file_put_contents($file, json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
@chmod($file, 0664);
|
||||
self::$cache = $new; // keep in-process cache fresh if you use it
|
||||
}
|
||||
|
||||
return ['changed' => $changed, 'moved' => $moved];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
|
||||
public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
{
|
||||
$old = self::normalizeFolder($oldFolder);
|
||||
$new = self::normalizeFolder($newFolder);
|
||||
if ($old === '' || $old === 'root') return; // nothing to re-key for root
|
||||
public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
{
|
||||
$old = self::normalizeFolder($oldFolder);
|
||||
$new = self::normalizeFolder($newFolder);
|
||||
if ($old === '' || $old === 'root') return; // nothing to re-key for root
|
||||
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
|
||||
|
||||
$rebased = [];
|
||||
foreach ($acl['folders'] as $k => $rec) {
|
||||
if ($k === $old || strpos($k, $old . '/') === 0) {
|
||||
$suffix = substr($k, strlen($old));
|
||||
$suffix = ltrim((string)$suffix, '/');
|
||||
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
|
||||
$rebased[$newKey] = $rec;
|
||||
} else {
|
||||
$rebased[$k] = $rec;
|
||||
$rebased = [];
|
||||
foreach ($acl['folders'] as $k => $rec) {
|
||||
if ($k === $old || strpos($k, $old . '/') === 0) {
|
||||
$suffix = substr($k, strlen($old));
|
||||
$suffix = ltrim((string)$suffix, '/');
|
||||
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
|
||||
$rebased[$newKey] = $rec;
|
||||
} else {
|
||||
$rebased[$k] = $rec;
|
||||
}
|
||||
}
|
||||
$acl['folders'] = $rebased;
|
||||
self::save($acl);
|
||||
}
|
||||
$acl['folders'] = $rebased;
|
||||
self::save($acl);
|
||||
}
|
||||
|
||||
private static function loadFresh(): array {
|
||||
private static function loadFresh(): array
|
||||
{
|
||||
$path = self::path();
|
||||
if (!is_file($path)) {
|
||||
@mkdir(dirname($path), 0755, true);
|
||||
@@ -94,7 +167,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
'read' => ['admin'],
|
||||
'write' => ['admin'],
|
||||
'share' => ['admin'],
|
||||
'read_own'=> [],
|
||||
'read_own' => [],
|
||||
'create' => [],
|
||||
'upload' => [],
|
||||
'edit' => [],
|
||||
@@ -130,12 +203,21 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
|
||||
$healed = false;
|
||||
foreach ($data['folders'] as $folder => &$rec) {
|
||||
if (!is_array($rec)) { $rec = []; $healed = true; }
|
||||
if (!is_array($rec)) {
|
||||
$rec = [];
|
||||
$healed = true;
|
||||
}
|
||||
foreach (self::BUCKETS as $k) {
|
||||
$v = $rec[$k] ?? [];
|
||||
if (!is_array($v)) { $v = []; $healed = true; }
|
||||
if (!is_array($v)) {
|
||||
$v = [];
|
||||
$healed = true;
|
||||
}
|
||||
$v = array_values(array_unique(array_map('strval', $v)));
|
||||
if (($rec[$k] ?? null) !== $v) { $rec[$k] = $v; $healed = true; }
|
||||
if (($rec[$k] ?? null) !== $v) {
|
||||
$rec[$k] = $v;
|
||||
$healed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($rec);
|
||||
@@ -145,19 +227,22 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function save(array $acl): bool {
|
||||
private static function save(array $acl): bool
|
||||
{
|
||||
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
|
||||
if ($ok) self::$cache = $acl;
|
||||
return $ok;
|
||||
}
|
||||
|
||||
private static function listFor(string $folder, string $key): array {
|
||||
private static function listFor(string $folder, string $key): array
|
||||
{
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$f = $acl['folders'][$folder] ?? null;
|
||||
return is_array($f[$key] ?? null) ? $f[$key] : [];
|
||||
}
|
||||
|
||||
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void {
|
||||
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
if (!isset($acl['folders'][$folder])) {
|
||||
@@ -182,19 +267,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
}
|
||||
}
|
||||
|
||||
public static function isAdmin(array $perms = []): bool {
|
||||
public static function isAdmin(array $perms = []): bool
|
||||
{
|
||||
if (!empty($_SESSION['isAdmin'])) return true;
|
||||
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
|
||||
if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
|
||||
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
|
||||
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
|
||||
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
|
||||
if (
|
||||
defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
|
||||
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function hasGrant(string $user, string $folder, string $cap): bool {
|
||||
public static function hasGrant(string $user, string $folder, string $cap): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$capKey = ($cap === 'owner') ? 'owners' : $cap;
|
||||
$arr = self::listFor($folder, $capKey);
|
||||
@@ -202,35 +291,41 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function isOwner(string $user, array $perms, string $folder): bool {
|
||||
public static function isOwner(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
|
||||
public static function canManage(string $user, array $perms, string $folder): bool {
|
||||
public static function canManage(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
return self::isOwner($user, $perms, $folder);
|
||||
}
|
||||
|
||||
public static function canRead(string $user, array $perms, string $folder): bool {
|
||||
public static function canRead(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'read');
|
||||
}
|
||||
|
||||
public static function canReadOwn(string $user, array $perms, string $folder): bool {
|
||||
public static function canReadOwn(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
if (self::canRead($user, $perms, $folder)) return true;
|
||||
return self::hasGrant($user, $folder, 'read_own');
|
||||
}
|
||||
|
||||
public static function canWrite(string $user, array $perms, string $folder): bool {
|
||||
public static function canWrite(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canShare(string $user, array $perms, string $folder): bool {
|
||||
public static function canShare(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
@@ -238,7 +333,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
}
|
||||
|
||||
// Legacy-only explicit (to avoid breaking existing callers)
|
||||
public static function explicit(string $folder): array {
|
||||
public static function explicit(string $folder): array
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$rec = $acl['folders'][$folder] ?? [];
|
||||
@@ -257,7 +353,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
}
|
||||
|
||||
// New: full explicit including granular
|
||||
public static function explicitAll(string $folder): array {
|
||||
public static function explicitAll(string $folder): array
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$rec = $acl['folders'][$folder] ?? [];
|
||||
@@ -285,7 +382,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
];
|
||||
}
|
||||
|
||||
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
|
||||
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$existing = $acl['folders'][$folder] ?? ['read_own' => []];
|
||||
@@ -314,19 +412,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
return self::save($acl);
|
||||
}
|
||||
|
||||
public static function applyUserGrantsAtomic(string $user, array $grants): array {
|
||||
public static function applyUserGrantsAtomic(string $user, array $grants): array
|
||||
{
|
||||
$user = (string)$user;
|
||||
$path = self::path();
|
||||
|
||||
$fh = @fopen($path, 'c+');
|
||||
if (!$fh) throw new RuntimeException('Cannot open ACL storage');
|
||||
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); }
|
||||
if (!flock($fh, LOCK_EX)) {
|
||||
fclose($fh);
|
||||
throw new RuntimeException('Cannot lock ACL storage');
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = stream_get_contents($fh);
|
||||
if ($raw === false) $raw = '';
|
||||
$acl = json_decode($raw, true);
|
||||
if (!is_array($acl)) $acl = ['folders'=>[], 'groups'=>[]];
|
||||
if (!is_array($acl)) $acl = ['folders' => [], 'groups' => []];
|
||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = [];
|
||||
if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = [];
|
||||
|
||||
@@ -335,7 +437,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
foreach ($grants as $folder => $caps) {
|
||||
$ff = self::normalizeFolder((string)$folder);
|
||||
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
|
||||
$rec =& $acl['folders'][$ff];
|
||||
$rec = &$acl['folders'][$ff];
|
||||
|
||||
foreach (self::BUCKETS as $k) {
|
||||
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
|
||||
@@ -365,10 +467,16 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
|
||||
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
|
||||
|
||||
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; }
|
||||
if ($m) {
|
||||
$v = true;
|
||||
$w = true;
|
||||
$u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true;
|
||||
}
|
||||
if ($u && !$v && !$vo) $vo = true;
|
||||
//if ($s && !$v) $v = true;
|
||||
if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; }
|
||||
if ($w) {
|
||||
$c = $u = $ed = $rn = $cp = $dl = $ex = true;
|
||||
}
|
||||
|
||||
if ($m) $rec['owners'][] = $user;
|
||||
if ($v) $rec['read'][] = $user;
|
||||
@@ -385,7 +493,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
if ($dl) $rec['delete'][] = $user;
|
||||
if ($ex) $rec['extract'][] = $user;
|
||||
if ($sf) $rec['share_file'][] = $user;
|
||||
if ($sfo)$rec['share_folder'][] = $user;
|
||||
if ($sfo) $rec['share_folder'][] = $user;
|
||||
|
||||
foreach (self::BUCKETS as $k) {
|
||||
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
|
||||
@@ -409,90 +517,102 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
}
|
||||
}
|
||||
|
||||
// --- Granular write family -----------------------------------------------
|
||||
// --- Granular write family -----------------------------------------------
|
||||
|
||||
public static function canCreate(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'create')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canCreate(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'create')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canCreateFolder(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
// Only owners/managers can create subfolders under $folder
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
public static function canCreateFolder(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
// Only owners/managers can create subfolders under $folder
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
|
||||
public static function canUpload(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'upload')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canUpload(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'upload')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canEdit(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'edit')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canEdit(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'edit')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canRename(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'rename')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canRename(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'rename')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canCopy(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'copy')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canCopy(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'copy')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canMove(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||
}
|
||||
public static function canMove(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||
}
|
||||
|
||||
public static function canMoveFolder(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||
}
|
||||
public static function canMoveFolder(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||
}
|
||||
|
||||
public static function canDelete(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'delete')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canDelete(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'delete')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canExtract(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'extract')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canExtract(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'extract')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
/** Sharing: files use share, folders require share + full-view. */
|
||||
public static function canShareFile(string $user, array $perms, string $folder): bool {
|
||||
public static function canShareFile(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||
}
|
||||
public static function canShareFolder(string $user, array $perms, string $folder): bool {
|
||||
public static function canShareFolder(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||
|
||||
@@ -62,27 +62,59 @@ class AdminModel
|
||||
return (int)$val;
|
||||
}
|
||||
|
||||
public static function buildPublicSubset(array $config): array
|
||||
/** Allow only http(s) URLs; return '' for invalid input. */
|
||||
private static function sanitizeHttpUrl($url): string
|
||||
{
|
||||
return [
|
||||
'header_title' => $config['header_title'] ?? 'FileRise',
|
||||
'loginOptions' => [
|
||||
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
|
||||
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
|
||||
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
|
||||
// do NOT include authBypass/authHeaderName here — admin-only
|
||||
],
|
||||
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
|
||||
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
|
||||
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
|
||||
'oidc' => [
|
||||
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||
// never include clientId / clientSecret
|
||||
],
|
||||
];
|
||||
$url = trim((string)$url);
|
||||
if ($url === '') return '';
|
||||
$valid = filter_var($url, FILTER_VALIDATE_URL);
|
||||
if (!$valid) return '';
|
||||
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?: '');
|
||||
return ($scheme === 'http' || $scheme === 'https') ? $url : '';
|
||||
}
|
||||
|
||||
public static function buildPublicSubset(array $config): array
|
||||
{
|
||||
$public = [
|
||||
'header_title' => $config['header_title'] ?? 'FileRise',
|
||||
'loginOptions' => [
|
||||
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
|
||||
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
|
||||
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
|
||||
],
|
||||
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
|
||||
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
|
||||
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
|
||||
'oidc' => [
|
||||
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||
],
|
||||
];
|
||||
|
||||
// NEW: include ONLYOFFICE minimal public flag
|
||||
$ooEnabled = null;
|
||||
if (isset($config['onlyoffice']['enabled'])) {
|
||||
$ooEnabled = (bool)$config['onlyoffice']['enabled'];
|
||||
} elseif (defined('ONLYOFFICE_ENABLED')) {
|
||||
$ooEnabled = (bool)ONLYOFFICE_ENABLED;
|
||||
}
|
||||
if ($ooEnabled !== null) {
|
||||
$public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||
}
|
||||
$locked = defined('ONLYOFFICE_ENABLED') || defined('ONLYOFFICE_JWT_SECRET')
|
||||
|| defined('ONLYOFFICE_DOCS_ORIGIN') || defined('ONLYOFFICE_PUBLIC_ORIGIN');
|
||||
|
||||
if ($locked) {
|
||||
$ooEnabled = defined('ONLYOFFICE_ENABLED') ? (bool)ONLYOFFICE_ENABLED : false;
|
||||
} else {
|
||||
$ooEnabled = isset($config['onlyoffice']['enabled']) ? (bool)$config['onlyoffice']['enabled'] : false;
|
||||
}
|
||||
|
||||
$public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||
|
||||
return $public;
|
||||
}
|
||||
|
||||
/** Write USERS_DIR/siteConfig.json atomically (unencrypted). */
|
||||
public static function writeSiteConfig(array $publicSubset): array
|
||||
{
|
||||
@@ -173,6 +205,28 @@ class AdminModel
|
||||
$configUpdate['loginOptions']['authHeaderName'] = trim($configUpdate['loginOptions']['authHeaderName']);
|
||||
}
|
||||
|
||||
// ---- ONLYOFFICE (persist, sanitize; keep secret unless explicitly replaced) ----
|
||||
if (isset($configUpdate['onlyoffice']) && is_array($configUpdate['onlyoffice'])) {
|
||||
$oo = $configUpdate['onlyoffice'];
|
||||
|
||||
$norm = [
|
||||
'enabled' => (bool)($oo['enabled'] ?? false),
|
||||
'docsOrigin' => self::sanitizeHttpUrl($oo['docsOrigin'] ?? ''),
|
||||
'publicOrigin' => self::sanitizeHttpUrl($oo['publicOrigin'] ?? ''),
|
||||
];
|
||||
|
||||
// Only accept a new secret if provided (non-empty). We do NOT clear on empty.
|
||||
if (array_key_exists('jwtSecret', $oo)) {
|
||||
$js = trim((string)$oo['jwtSecret']);
|
||||
if ($js !== '') {
|
||||
if (strlen($js) > 1024) $js = substr($js, 0, 1024);
|
||||
$norm['jwtSecret'] = $js; // will be encrypted with encryptData()
|
||||
}
|
||||
}
|
||||
|
||||
$configUpdate['onlyoffice'] = $norm;
|
||||
}
|
||||
|
||||
// Convert configuration to JSON.
|
||||
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
|
||||
if ($plainTextConfig === false) {
|
||||
@@ -301,6 +355,19 @@ class AdminModel
|
||||
$config['sharedMaxUploadSize'] = (int)min((int)$config['sharedMaxUploadSize'], $maxBytes);
|
||||
}
|
||||
|
||||
// ---- Ensure ONLYOFFICE structure exists, sanitize values ----
|
||||
if (!isset($config['onlyoffice']) || !is_array($config['onlyoffice'])) {
|
||||
$config['onlyoffice'] = [
|
||||
'enabled' => false,
|
||||
'docsOrigin' => '',
|
||||
'publicOrigin' => '',
|
||||
];
|
||||
} else {
|
||||
$config['onlyoffice']['enabled'] = (bool)($config['onlyoffice']['enabled'] ?? false);
|
||||
$config['onlyoffice']['docsOrigin'] = self::sanitizeHttpUrl($config['onlyoffice']['docsOrigin'] ?? '');
|
||||
$config['onlyoffice']['publicOrigin'] = self::sanitizeHttpUrl($config['onlyoffice']['publicOrigin'] ?? '');
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
@@ -320,7 +387,12 @@ class AdminModel
|
||||
],
|
||||
'globalOtpauthUrl' => "",
|
||||
'enableWebDAV' => false,
|
||||
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE))
|
||||
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE)),
|
||||
'onlyoffice' => [
|
||||
'enabled' => false,
|
||||
'docsOrigin' => '',
|
||||
'publicOrigin' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -557,59 +557,104 @@ class FileModel {
|
||||
* @return array An associative array with either an "error" key or a "zipPath" key.
|
||||
*/
|
||||
public static function createZipArchive($folder, $files) {
|
||||
// Validate and build folder path.
|
||||
$folder = trim($folder) ?: 'root';
|
||||
// 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);
|
||||
if ($baseDir === false) {
|
||||
return ["error" => "Uploads directory not configured correctly."];
|
||||
}
|
||||
|
||||
if (strtolower($folder) === 'root' || $folder === "") {
|
||||
$folderPathReal = $baseDir;
|
||||
} else {
|
||||
// Prevent path traversal.
|
||||
if (strpos($folder, '..') !== false) {
|
||||
return ["error" => "Invalid folder name."];
|
||||
}
|
||||
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . trim($folder, "/\\ ");
|
||||
$parts = explode('/', trim($folder, "/\\ "));
|
||||
foreach ($parts as $part) {
|
||||
if ($part === '' || !preg_match(REGEX_FOLDER_NAME, $part)) {
|
||||
return ["error" => "Invalid folder name."];
|
||||
}
|
||||
}
|
||||
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||
$folderPathReal = realpath($folderPath);
|
||||
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
|
||||
return ["error" => "Folder not found."];
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each file and build an array of files to zip.
|
||||
|
||||
// Collect files to zip (only regular files in the chosen folder)
|
||||
$filesToZip = [];
|
||||
foreach ($files as $fileName) {
|
||||
// Validate file name using REGEX_FILE_NAME.
|
||||
$fileName = basename(trim($fileName));
|
||||
$fileName = basename(trim((string)$fileName));
|
||||
if (!preg_match(REGEX_FILE_NAME, $fileName)) {
|
||||
continue;
|
||||
}
|
||||
$fullPath = $folderPathReal . DIRECTORY_SEPARATOR . $fileName;
|
||||
if (file_exists($fullPath)) {
|
||||
// Skip symlinks (avoid archiving outside targets via links)
|
||||
if (is_link($fullPath)) {
|
||||
continue;
|
||||
}
|
||||
if (is_file($fullPath)) {
|
||||
$filesToZip[] = $fullPath;
|
||||
}
|
||||
}
|
||||
if (empty($filesToZip)) {
|
||||
return ["error" => "No valid files found to zip."];
|
||||
}
|
||||
|
||||
// Create a temporary ZIP file.
|
||||
$tempZip = tempnam(sys_get_temp_dir(), 'zip');
|
||||
unlink($tempZip); // Remove the temp file so that ZipArchive can create a new file.
|
||||
$tempZip .= '.zip';
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($tempZip, ZipArchive::CREATE) !== TRUE) {
|
||||
|
||||
// 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) || !is_writable($work)) {
|
||||
return ["error" => "ZIP temp dir not writable: " . $work];
|
||||
}
|
||||
|
||||
// Optional sanity: ensure there is roughly enough free space
|
||||
$totalSize = 0;
|
||||
foreach ($filesToZip as $fp) {
|
||||
$sz = @filesize($fp);
|
||||
if ($sz !== false) $totalSize += (int)$sz;
|
||||
}
|
||||
$free = @disk_free_space($work);
|
||||
// Add ~20MB overhead and a 5% cushion
|
||||
if ($free !== false && $totalSize > 0) {
|
||||
$needed = (int)ceil($totalSize * 1.05) + (20 * 1024 * 1024);
|
||||
if ($free < $needed) {
|
||||
return ["error" => "Insufficient free space in ZIP workspace."];
|
||||
}
|
||||
}
|
||||
|
||||
@set_time_limit(0);
|
||||
|
||||
// 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;
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
return ["error" => "Could not create zip archive."];
|
||||
}
|
||||
// Add each file using its base name.
|
||||
|
||||
foreach ($filesToZip as $filePath) {
|
||||
// Add using basename at the root of the zip (matches current behavior)
|
||||
$zip->addFile($filePath, basename($filePath));
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return ["zipPath" => $tempZip];
|
||||
|
||||
if (!$zip->close()) {
|
||||
// Commonly indicates disk full at finalize
|
||||
return ["error" => "Failed to finalize ZIP (disk full?)."];
|
||||
}
|
||||
|
||||
// Success: controller will readfile() and unlink()
|
||||
return ["zipPath" => $zipPath];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -623,15 +668,23 @@ class FileModel {
|
||||
$errors = [];
|
||||
$allSuccess = true;
|
||||
$extractedFiles = [];
|
||||
|
||||
|
||||
// Config toggles
|
||||
$SKIP_DOTFILES = defined('SKIP_DOTFILES_ON_EXTRACT') ? (bool)SKIP_DOTFILES_ON_EXTRACT : true;
|
||||
|
||||
// Hard limits to mitigate zip-bombs (tweak via defines if you like)
|
||||
$MAX_UNZIP_BYTES = defined('MAX_UNZIP_BYTES') ? (int)MAX_UNZIP_BYTES : (200 * 1024 * 1024 * 1024); // 200 GiB
|
||||
$MAX_UNZIP_FILES = defined('MAX_UNZIP_FILES') ? (int)MAX_UNZIP_FILES : 20000;
|
||||
|
||||
$baseDir = realpath(UPLOAD_DIR);
|
||||
if ($baseDir === false) {
|
||||
return ["error" => "Uploads directory not configured correctly."];
|
||||
}
|
||||
|
||||
|
||||
// Build target dir
|
||||
if (strtolower(trim($folder) ?: '') === "root") {
|
||||
$relativePath = "";
|
||||
$folderNorm = "root";
|
||||
} else {
|
||||
$parts = explode('/', trim($folder, "/\\"));
|
||||
foreach ($parts as $part) {
|
||||
@@ -640,9 +693,10 @@ class FileModel {
|
||||
}
|
||||
}
|
||||
$relativePath = implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR;
|
||||
$folderNorm = implode('/', $parts); // normalized with forward slashes for metadata helpers
|
||||
}
|
||||
|
||||
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath;
|
||||
|
||||
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath;
|
||||
if (!is_dir($folderPath) && !mkdir($folderPath, 0775, true)) {
|
||||
return ["error" => "Folder not found and cannot be created."];
|
||||
}
|
||||
@@ -650,17 +704,74 @@ class FileModel {
|
||||
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
|
||||
return ["error" => "Folder not found."];
|
||||
}
|
||||
|
||||
// Prepare metadata container
|
||||
$metadataFile = self::getMetadataFilePath($folder);
|
||||
$destMetadata = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
|
||||
|
||||
|
||||
// Metadata cache per folder to avoid many reads/writes
|
||||
$metaCache = [];
|
||||
$getMeta = function(string $folderStr) use (&$metaCache) {
|
||||
if (!isset($metaCache[$folderStr])) {
|
||||
$mf = self::getMetadataFilePath($folderStr);
|
||||
$metaCache[$folderStr] = file_exists($mf) ? (json_decode(file_get_contents($mf), true) ?: []) : [];
|
||||
}
|
||||
return $metaCache[$folderStr];
|
||||
};
|
||||
$putMeta = function(string $folderStr, array $meta) use (&$metaCache) {
|
||||
$metaCache[$folderStr] = $meta;
|
||||
};
|
||||
|
||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||
$actor = $_SESSION['username'] ?? 'Unknown';
|
||||
$now = date(DATE_TIME_FORMAT);
|
||||
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// Reject absolute paths, traversal, drive letters
|
||||
$isUnsafeEntryPath = function(string $entry) : bool {
|
||||
$e = str_replace('\\', '/', $entry);
|
||||
if ($e === '' || str_contains($e, "\0")) return true;
|
||||
if (str_starts_with($e, '/')) return true; // absolute nix path
|
||||
if (preg_match('/^[A-Za-z]:[\\/]/', $e)) return true; // Windows drive
|
||||
if (str_contains($e, '../') || str_contains($e, '..\\')) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Validate each subfolder name in the path using REGEX_FOLDER_NAME
|
||||
$validEntrySubdirs = function(string $entry) : bool {
|
||||
$e = trim(str_replace('\\', '/', $entry), '/');
|
||||
if ($e === '') return true;
|
||||
$dirs = explode('/', $e);
|
||||
array_pop($dirs); // remove basename; we only validate directories here
|
||||
foreach ($dirs as $d) {
|
||||
if ($d === '' || !preg_match(REGEX_FOLDER_NAME, $d)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// NEW: hidden path detector — true if ANY segment starts with '.'
|
||||
$isHiddenDotPath = function(string $entry) : bool {
|
||||
$e = trim(str_replace('\\', '/', $entry), '/');
|
||||
if ($e === '') return false;
|
||||
foreach (explode('/', $e) as $seg) {
|
||||
if ($seg !== '' && $seg[0] === '.') return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Generalized metadata stamper: writes to the specified folder's metadata.json
|
||||
$stampMeta = function(string $folderStr, string $basename) use (&$getMeta, &$putMeta, $actor, $now) {
|
||||
$meta = $getMeta($folderStr);
|
||||
$meta[$basename] = [
|
||||
'uploaded' => $now,
|
||||
'modified' => $now,
|
||||
'uploader' => $actor,
|
||||
];
|
||||
$putMeta($folderStr, $meta);
|
||||
};
|
||||
|
||||
// No PHP execution time limit during heavy work
|
||||
@set_time_limit(0);
|
||||
|
||||
foreach ($files as $zipFileName) {
|
||||
$zipBase = basename(trim($zipFileName));
|
||||
$zipBase = basename(trim((string)$zipFileName));
|
||||
if (strtolower(substr($zipBase, -4)) !== '.zip') {
|
||||
continue;
|
||||
}
|
||||
@@ -669,76 +780,135 @@ class FileModel {
|
||||
$allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
$zipFilePath = $folderPathReal . DIRECTORY_SEPARATOR . $zipBase;
|
||||
if (!file_exists($zipFilePath)) {
|
||||
$errors[] = "$zipBase does not exist in folder.";
|
||||
$allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFilePath) !== TRUE) {
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipFilePath) !== true) {
|
||||
$errors[] = "Could not open $zipBase as a zip file.";
|
||||
$allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Minimal Zip Slip guard: fail if any entry looks unsafe
|
||||
|
||||
// ---- Pre-scan: safety and size limits + build allow-list (skip dotfiles) ----
|
||||
$unsafe = false;
|
||||
$totalUncompressed = 0;
|
||||
$fileCount = 0;
|
||||
$allowedEntries = []; // names to extract (files and/or directories)
|
||||
$allowedFiles = []; // only files (for metadata stamping)
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$entryName = $zip->getNameIndex($i);
|
||||
if ($entryName === false) { $unsafe = true; break; }
|
||||
// Absolute paths, parent traversal, or Windows drive paths
|
||||
if (strpos($entryName, '../') !== false || strpos($entryName, '..\\') !== false ||
|
||||
str_starts_with($entryName, '/') || preg_match('/^[A-Za-z]:[\\\\\\/]/', $entryName)) {
|
||||
$stat = $zip->statIndex($i);
|
||||
$name = $zip->getNameIndex($i);
|
||||
if ($name === false || !$stat) { $unsafe = true; break; }
|
||||
|
||||
$isDir = str_ends_with($name, '/');
|
||||
|
||||
// Basic path checks
|
||||
if ($isUnsafeEntryPath($name) || !$validEntrySubdirs($name)) { $unsafe = true; break; }
|
||||
|
||||
// Skip hidden entries (any segment starts with '.')
|
||||
if ($SKIP_DOTFILES && $isHiddenDotPath($name)) {
|
||||
continue; // just ignore; do not treat as unsafe
|
||||
}
|
||||
|
||||
// Detect symlinks via external attributes (best-effort)
|
||||
$mode = (isset($stat['external_attributes']) ? (($stat['external_attributes'] >> 16) & 0xF000) : 0);
|
||||
if ($mode === 0120000) { // S_IFLNK
|
||||
$unsafe = true; break;
|
||||
}
|
||||
|
||||
// Track limits only for files we're going to extract
|
||||
if (!$isDir) {
|
||||
$fileCount++;
|
||||
$sz = isset($stat['size']) ? (int)$stat['size'] : 0;
|
||||
$totalUncompressed += $sz;
|
||||
if ($fileCount > $MAX_UNZIP_FILES || $totalUncompressed > $MAX_UNZIP_BYTES) {
|
||||
$unsafe = true; break;
|
||||
}
|
||||
$allowedFiles[] = $name;
|
||||
}
|
||||
|
||||
$allowedEntries[] = $name;
|
||||
}
|
||||
|
||||
if ($unsafe) {
|
||||
$zip->close();
|
||||
$errors[] = "$zipBase contains unsafe paths; extraction aborted.";
|
||||
$errors[] = "$zipBase contains unsafe or oversized contents; extraction aborted.";
|
||||
$allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract safely (whole archive) after precheck
|
||||
if (!$zip->extractTo($folderPathReal)) {
|
||||
|
||||
// Nothing to extract after filtering?
|
||||
if (empty($allowedEntries)) {
|
||||
$zip->close();
|
||||
// Treat as success (nothing visible to extract), but informatively note it
|
||||
$errors[] = "$zipBase contained only hidden or unsupported entries.";
|
||||
$allSuccess = false; // or keep true if you'd rather not mark as failure
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- Extract ONLY the allowed entries ----
|
||||
if (!$zip->extractTo($folderPathReal, $allowedEntries)) {
|
||||
$errors[] = "Failed to extract $zipBase.";
|
||||
$allSuccess = false;
|
||||
$zip->close();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stamp metadata for extracted regular files
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$entryName = $zip->getNameIndex($i);
|
||||
if ($entryName === false) continue;
|
||||
|
||||
$basename = basename($entryName);
|
||||
|
||||
// ---- Stamp metadata for files in the target folder AND nested subfolders (allowed files only) ----
|
||||
foreach ($allowedFiles as $entryName) {
|
||||
// Normalize entry path for filesystem checks
|
||||
$entryFsRel = str_replace(['\\'], '/', $entryName);
|
||||
$entryFsRel = ltrim($entryFsRel, '/'); // ensure relative
|
||||
|
||||
// Skip any directories (shouldn't be listed here, but defend anyway)
|
||||
if ($entryFsRel === '' || str_ends_with($entryFsRel, '/')) continue;
|
||||
|
||||
$basename = basename($entryFsRel);
|
||||
if ($basename === '' || !preg_match($safeFileNamePattern, $basename)) continue;
|
||||
|
||||
// Only stamp files that actually exist after extraction
|
||||
$target = $folderPathReal . DIRECTORY_SEPARATOR . $entryName;
|
||||
$isDir = str_ends_with($entryName, '/') || is_dir($target);
|
||||
if ($isDir) continue;
|
||||
|
||||
$extractedFiles[] = $basename;
|
||||
$destMetadata[$basename] = [
|
||||
'uploaded' => $now,
|
||||
'modified' => $now,
|
||||
'uploader' => $actor,
|
||||
// no tags by default
|
||||
];
|
||||
|
||||
// Decide which folder's metadata to update:
|
||||
// - top-level files -> $folderNorm
|
||||
// - nested files -> corresponding "<folderNorm>/<sub/dir>" (or "sub/dir" if folderNorm is 'root')
|
||||
$relDir = str_replace('\\', '/', trim(dirname($entryFsRel), '.'));
|
||||
$relDir = ($relDir === '.' ? '' : trim($relDir, '/'));
|
||||
|
||||
$targetFolderNorm = ($relDir === '' || $relDir === '.')
|
||||
? $folderNorm
|
||||
: (($folderNorm === 'root') ? $relDir : ($folderNorm . '/' . $relDir));
|
||||
|
||||
// Only stamp if the file actually exists on disk after extraction
|
||||
$targetAbs = $folderPathReal . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $entryFsRel);
|
||||
if (is_file($targetAbs)) {
|
||||
// Preserve list behavior: only include top-level extracted names
|
||||
if ($relDir === '' || $relDir === '.') {
|
||||
$extractedFiles[] = $basename;
|
||||
}
|
||||
$stampMeta($targetFolderNorm, $basename);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
if (file_put_contents($metadataFile, json_encode($destMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||
$errors[] = "Failed to update metadata.";
|
||||
$allSuccess = false;
|
||||
|
||||
// Persist metadata for any touched folder(s)
|
||||
foreach ($metaCache as $folderStr => $meta) {
|
||||
$metadataFile = self::getMetadataFilePath($folderStr);
|
||||
if (!is_dir(dirname($metadataFile))) {
|
||||
@mkdir(dirname($metadataFile), 0775, true);
|
||||
}
|
||||
if (file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||
$errors[] = "Failed to update metadata for {$folderStr}.";
|
||||
$allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return $allSuccess
|
||||
? ["success" => true, "extractedFiles" => $extractedFiles]
|
||||
: ["success" => false, "error" => implode(" ", $errors)];
|
||||
|
||||
90
src/models/FolderMeta.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
// src/models/FolderMeta.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PROJECT_ROOT . '/config/config.php';
|
||||
require_once __DIR__ . '/../../src/lib/ACL.php';
|
||||
|
||||
class FolderMeta
|
||||
{
|
||||
private static function path(): string {
|
||||
return rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_colors.json';
|
||||
}
|
||||
|
||||
public static function normalizeFolder(string $folder): string {
|
||||
$f = trim(str_replace('\\','/',$folder), "/ \t\r\n");
|
||||
return ($f === '' || $f === 'root') ? 'root' : $f;
|
||||
}
|
||||
|
||||
/** Normalize hex (accepts #RGB or #RRGGBB, returns #RRGGBB) */
|
||||
public static function normalizeHex(?string $hex): ?string {
|
||||
if ($hex === null || $hex === '') return null;
|
||||
if (!preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $hex)) {
|
||||
throw new \InvalidArgumentException('Invalid color hex');
|
||||
}
|
||||
if (strlen($hex) === 4) {
|
||||
$hex = '#' . $hex[1].$hex[1] . $hex[2].$hex[2] . $hex[3].$hex[3];
|
||||
}
|
||||
return strtoupper($hex);
|
||||
}
|
||||
|
||||
/** Read full map from disk */
|
||||
public static function getMap(): array {
|
||||
$file = self::path();
|
||||
$raw = @file_get_contents($file);
|
||||
$map = is_string($raw) ? json_decode($raw, true) : [];
|
||||
return is_array($map) ? $map : [];
|
||||
}
|
||||
|
||||
/** Write full map to disk (atomic-ish) */
|
||||
private static function writeMap(array $map): void {
|
||||
$file = self::path();
|
||||
$dir = dirname($file);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
$tmp = $file . '.tmp';
|
||||
@file_put_contents($tmp, json_encode($map, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
@rename($tmp, $file);
|
||||
@chmod($file, 0664);
|
||||
}
|
||||
|
||||
/** Set or clear a color for one folder */
|
||||
public static function setColor(string $folder, ?string $hex): array {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$hex = self::normalizeHex($hex);
|
||||
$map = self::getMap();
|
||||
|
||||
if ($hex === null) unset($map[$folder]);
|
||||
else $map[$folder] = $hex;
|
||||
|
||||
self::writeMap($map);
|
||||
return ['folder'=>$folder, 'color'=>$map[$folder] ?? null];
|
||||
}
|
||||
|
||||
/** Migrate color entries for a whole subtree (used by move/rename) */
|
||||
public static function migrateSubtree(string $source, string $target): array {
|
||||
$src = self::normalizeFolder($source);
|
||||
$dst = self::normalizeFolder($target);
|
||||
if ($src === 'root') return ['changed'=>false, 'moved'=>0];
|
||||
|
||||
$map = self::getMap();
|
||||
if (!$map) return ['changed'=>false, 'moved'=>0];
|
||||
|
||||
$new = $map;
|
||||
$moved = 0;
|
||||
|
||||
foreach ($map as $key => $hex) {
|
||||
$isSelf = ($key === $src);
|
||||
$isSub = str_starts_with($key.'/', $src.'/');
|
||||
if (!$isSelf && !$isSub) continue;
|
||||
|
||||
unset($new[$key]);
|
||||
$suffix = substr($key, strlen($src)); // '' or '/child/...'
|
||||
$newKey = $dst === 'root' ? ltrim($suffix,'/') : rtrim($dst,'/') . $suffix;
|
||||
$new[$newKey] = $hex;
|
||||
$moved++;
|
||||
}
|
||||
|
||||
if ($moved) self::writeMap($new);
|
||||
return ['changed'=> (bool)$moved, 'moved'=> $moved];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
94
src/models/MediaModel.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
// src/models/MediaModel.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PROJECT_ROOT . '/config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
|
||||
class MediaModel
|
||||
{
|
||||
private static function baseDir(): string {
|
||||
$dir = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . 'user_state';
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
return $dir . DIRECTORY_SEPARATOR;
|
||||
}
|
||||
|
||||
private static function filePathFor(string $username): string {
|
||||
// case-insensitive username file
|
||||
$safe = strtolower(preg_replace('/[^a-z0-9_\-\.]/i', '_', $username));
|
||||
return self::baseDir() . $safe . '_media.json';
|
||||
}
|
||||
|
||||
private static function loadState(string $username): array {
|
||||
$path = self::filePathFor($username);
|
||||
if (!file_exists($path)) return ["version"=>1, "items"=>[]];
|
||||
$json = file_get_contents($path);
|
||||
$data = json_decode($json, true);
|
||||
return (is_array($data) && isset($data['items'])) ? $data : ["version"=>1, "items"=>[]];
|
||||
}
|
||||
|
||||
private static function saveState(string $username, array $state): bool {
|
||||
$path = self::filePathFor($username);
|
||||
$tmp = $path . '.tmp';
|
||||
$ok = file_put_contents($tmp, json_encode($state, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOCK_EX);
|
||||
if ($ok === false) return false;
|
||||
return @rename($tmp, $path);
|
||||
}
|
||||
|
||||
/** Save/merge a single file progress record. */
|
||||
public static function saveProgress(string $username, string $folder, string $file, float $seconds, ?float $duration, ?bool $completed): array {
|
||||
$folderKey = ($folder === '' || strtolower($folder)==='root') ? 'root' : $folder;
|
||||
$nowIso = date('c');
|
||||
|
||||
$state = self::loadState($username);
|
||||
if (!isset($state['items'][$folderKey])) $state['items'][$folderKey] = [];
|
||||
if (!isset($state['items'][$folderKey][$file])) {
|
||||
$state['items'][$folderKey][$file] = [
|
||||
"seconds" => 0,
|
||||
"duration" => $duration ?? 0,
|
||||
"completed" => false,
|
||||
"updatedAt" => $nowIso
|
||||
];
|
||||
}
|
||||
|
||||
$row =& $state['items'][$folderKey][$file];
|
||||
if ($duration !== null && $duration > 0) $row['duration'] = $duration;
|
||||
if ($seconds >= 0) $row['seconds'] = $seconds;
|
||||
if ($completed !== null) $row['completed'] = (bool)$completed;
|
||||
// auto-complete if we’re basically done
|
||||
if (!$row['completed'] && $row['duration'] > 0 && $row['seconds'] >= max(0, $row['duration'] * 0.95)) {
|
||||
$row['completed'] = true;
|
||||
}
|
||||
$row['updatedAt'] = $nowIso;
|
||||
|
||||
self::saveState($username, $state);
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** Get a single file progress record. */
|
||||
public static function getProgress(string $username, string $folder, string $file): array {
|
||||
$folderKey = ($folder === '' || strtolower($folder)==='root') ? 'root' : $folder;
|
||||
$state = self::loadState($username);
|
||||
$row = $state['items'][$folderKey][$file] ?? null;
|
||||
return is_array($row) ? $row : ["seconds"=>0,"duration"=>0,"completed"=>false,"updatedAt"=>null];
|
||||
}
|
||||
|
||||
/** Folder map: filename => {seconds,duration,completed,updatedAt} */
|
||||
public static function getFolderMap(string $username, string $folder): array {
|
||||
$folderKey = ($folder === '' || strtolower($folder)==='root') ? 'root' : $folder;
|
||||
$state = self::loadState($username);
|
||||
$items = $state['items'][$folderKey] ?? [];
|
||||
return is_array($items) ? $items : [];
|
||||
}
|
||||
|
||||
/** Clear one file’s progress (e.g., “mark unviewed”). */
|
||||
public static function clearProgress(string $username, string $folder, string $file): bool {
|
||||
$folderKey = ($folder === '' || strtolower($folder)==='root') ? 'root' : $folder;
|
||||
$state = self::loadState($username);
|
||||
if (isset($state['items'][$folderKey][$file])) {
|
||||
unset($state['items'][$folderKey][$file]);
|
||||
return self::saveState($username, $state);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||