Compare commits

...

68 Commits

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

Closes #66
2025-11-13 05:06:24 -05:00
Ryan
f1dcc0df24 ci(release): run Release on workflow_run; fix(css): remove folder SVG lip-highlight stroke 2025-11-11 01:04:31 -05:00
github-actions[bot]
ba9ead666d chore(release): set APP_VERSION to v1.9.3 [skip ci] 2025-11-11 05:09:24 +00:00
Ryan
dbdf760d4d release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release 2025-11-11 00:09:15 -05:00
Ryan
a031fc99c2 release(ci): harden release-on-version workflow; remove sleep/race, safer checkout, deterministic ref 2025-11-10 03:01:46 -05:00
github-actions[bot]
db73cf2876 chore(release): set APP_VERSION to v1.9.2 [skip ci] 2025-11-10 07:50:29 +00:00
Ryan
062f34dd3d release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback) 2025-11-10 02:50:19 -05:00
Ryan
63b24ba698 chore(doc): readme adjustments for webdav & security 2025-11-09 21:59:20 -05:00
Ryan
567d2f62e8 chore(doc) readme updated to remove duplicated onlyoffice info 2025-11-09 20:19:30 -05:00
Ryan
9be53ba033 chore(scripts): fix shellcheck SC2148 and harden manual-sync.sh 2025-11-09 20:01:21 -05:00
github-actions[bot]
de925e6fc2 chore(release): set APP_VERSION to v1.9.1 [skip ci] 2025-11-10 00:55:18 +00:00
Ryan
bd7ff4d9cd release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script 2025-11-09 19:55:07 -05:00
Ryan
6727cc66ac docs(assets): refresh screenshots to showcase new Folder Manager 2025-11-09 02:48:26 -05:00
Ryan
f3269877c7 Update image link in README.md 2025-11-09 02:41:38 -05:00
github-actions[bot]
5ffe9b3ffc chore(release): set APP_VERSION to v1.9.0 [skip ci] 2025-11-09 06:45:49 +00:00
Ryan
abd3dad5a5 release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening 2025-11-09 01:45:39 -05:00
github-actions[bot]
4c849b1dc3 chore(release): set APP_VERSION to v1.8.13 [skip ci] 2025-11-09 00:30:43 +00:00
Ryan
7cc314179f release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync 2025-11-08 19:30:33 -05:00
github-actions[bot]
9ddb633cca chore(release): set APP_VERSION to v1.8.12 [skip ci] 2025-11-08 21:05:31 +00:00
Ryan
448e246689 release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons 2025-11-08 16:05:20 -05:00
Ryan
dc7797e50d chore(doc): readme spacing issue fixed 2025-11-08 14:57:54 -05:00
Ryan
913d370ef2 Update README with new gif and remove dark mode image 2025-11-08 14:36:51 -05:00
github-actions[bot]
488b5cb532 chore(release): set APP_VERSION to v1.8.11 [skip ci] 2025-11-08 19:12:57 +00:00
Ryan
15b5aa6d8d release(v1.8.11): doc updated 2025-11-08 14:12:48 -05:00
Ryan
8f03cc7456 release (v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client 2025-11-08 13:53:11 -05:00
github-actions[bot]
c9a99506d7 chore(release): set APP_VERSION to v1.8.10 [skip ci] 2025-11-08 18:33:52 +00:00
Ryan
04ec0a0830 release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul 2025-11-08 13:33:38 -05:00
github-actions[bot]
429cd0314a chore(release): set APP_VERSION to v1.8.9 [skip ci] 2025-11-08 03:10:24 +00:00
Ryan
ba29cc4822 release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64) 2025-11-07 22:10:14 -05:00
github-actions[bot]
e2cd304158 chore(release): set APP_VERSION to v1.8.8 [skip ci] 2025-11-07 07:57:42 +00:00
Ryan
ca8788a694 release(v1.8.8): background ZIP jobs w/ tokenized download + in‑modal progress bar; robust finalize; janitor cleanup — closes #60 2025-11-07 02:57:30 -05:00
Ryan
dc45fed886 chore(ci): increase release delay to 10m to avoid ref replication race 2025-11-05 00:19:56 -05:00
github-actions[bot]
a9fe342175 chore(release): set APP_VERSION to v1.8.7 [skip ci] 2025-11-05 05:02:42 +00:00
Ryan
7669f5a10b release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives 2025-11-05 00:02:32 -05:00
Ryan
34a4e06a23 chore(ci): add manual trigger + bot-derived version detection for releases 2025-11-04 23:09:31 -05:00
github-actions[bot]
d00faf5fe7 chore(release): set APP_VERSION to v1.8.6 [skip ci] 2025-11-05 03:57:04 +00:00
Ryan
ad8cbc601a release(v1.8.6): fix large ZIP downloads + safer extract; close #60 2025-11-04 22:56:53 -05:00
Ryan
40e000b5bc chore(ci): release uses correct commit for version.js + harden workflow_run 2025-11-04 22:22:24 -05:00
Ryan
eee25a4dc6 ci: revert but keep delay 2025-11-04 22:04:15 -05:00
github-actions[bot]
d66f4d93cb chore(release): set APP_VERSION to v1.8.5 [skip ci] 2025-11-05 02:17:05 +00:00
Ryan
f4f7f8ef38 release(v1.8.5): ci: reduce pre-run delay to 2-min and add missing needs: delay, final test 2025-11-04 21:16:55 -05:00
github-actions[bot]
0ccba45c40 chore(release): set APP_VERSION to v1.8.4 [skip ci] 2025-11-05 02:09:07 +00:00
Ryan
620c916eb3 release(v1.8.4): ci: add 3-min pre-run delay to avoid workflow_run races 2025-11-04 21:08:58 -05:00
github-actions[bot]
f809cc09d2 chore(release): set APP_VERSION to v1.8.3 [skip ci] 2025-11-05 01:58:42 +00:00
Ryan
6758b5f73d release(v1.8.3): feat(mobile+ci): harden Capacitor switcher & make release-on-version robust 2025-11-04 20:58:34 -05:00
github-actions[bot]
30a0aaf05e chore(release): set APP_VERSION to v1.8.2 [skip ci] 2025-11-05 01:34:51 +00:00
Ryan
c843f00738 release(v1.8.2): media progress tracking + watched badges; PWA scaffolding; mobile switcher (closes #37) 2025-11-04 20:34:42 -05:00
github-actions[bot]
4bb9d81370 chore(release): set APP_VERSION to v1.8.1 [skip ci] 2025-11-03 21:59:58 +00:00
Ryan
29e0497730 release(v1.8.1): fix(security,onlyoffice): sanitize DS origin; safe api.js/iframe probes; better UX placeholder 2025-11-03 16:59:47 -05:00
github-actions[bot]
dd3a7a5145 chore(release): set APP_VERSION to v1.8.0 [skip ci] 2025-11-03 21:40:02 +00:00
Ryan
d00db803c3 release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers
Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately.
2025-11-03 16:39:48 -05:00
96 changed files with 14878 additions and 7156 deletions

View File

@@ -2,13 +2,18 @@
name: Release on version.js update
on:
push:
branches: ["master"]
paths:
- public/js/version.js
workflow_run:
workflows: ["Bump version and sync Changelog to Docker Repo"]
types: [completed]
branches: [master]
workflow_dispatch:
inputs:
ref:
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,64 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
if: |
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch'
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
if git ls-remote --exit-code --heads https://github.com/${{ github.repository }}.git "$REF_IN" >/dev/null 2>&1; then
REF="$REF_IN"
else
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
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 +92,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,55 +100,89 @@ 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 dont 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)
# --- PHP + Composer for vendor/ (production) ---
- name: Setup PHP
if: steps.tagcheck.outputs.exists == 'false'
id: php
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
extensions: mbstring, json, curl, dom, fileinfo, openssl, zip
coverage: none
ini-values: memory_limit=-1
- name: Cache Composer downloads
if: steps.tagcheck.outputs.exists == 'false'
uses: actions/cache@v4
with:
path: |
~/.composer/cache
~/.cache/composer
key: composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-
- name: Install PHP dependencies into staging
if: steps.tagcheck.outputs.exists == 'false'
env:
COMPOSER_MEMORY_LIMIT: -1
shell: bash
run: |
set -euo pipefail
pushd staging >/dev/null
if [[ -f composer.json ]]; then
composer install \
--no-dev \
--prefer-dist \
--no-interaction \
--no-progress \
--optimize-autoloader \
--classmap-authoritative
test -f vendor/autoload.php || (echo "Composer install did not produce vendor/autoload.php" >&2; exit 1)
else
echo "No composer.json in staging; skipping vendor install."
fi
popd >/dev/null
# --- end Composer ---
- name: Verify placeholders removed (skip vendor/)
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
set -euo pipefail
ROOT="$(pwd)/staging"
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
--exclude-dir=vendor --exclude-dir=vendor-bin \
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
echo "---- 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 (includes vendor/)
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 +227,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 +240,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 +255,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 +262,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

View File

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

479
README.md
View File

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

View File

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

View File

@@ -1,38 +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
# Never redirect local/dev hosts
# 0) Let ACME http-01 pass BEFORE any other rule (needed for auto-renew)
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
RewriteRule - - [L]
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
RewriteRule "(^|/)\.(?!well-known/)" - [F]
# 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]
# --- HTTPS redirect ---
# Use ONE of these blocks.
# 4) HTTPS redirect (enable ONE of these, comment the other)
# A) Direct TLS on this server (enable this if Apache terminates HTTPS here)
#RewriteCond %{HTTPS} off
# 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
@@ -40,7 +65,7 @@ RewriteRule ^ - [L]
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"
@@ -51,59 +76,53 @@ RewriteRule ^ - [L]
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.)
# HSTS only when HTTPS (safe for .htaccess)
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" env=HTTPS
# CSP — keep this SHA-256 in sync with your inline pre-theme script
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
</IfModule>
# --- Caching (query-string based, no env vars needed) ---
# ---------------- Caching ----------------
<IfModule mod_headers.c>
# HTML/PHP: no cache (only if PHP didnt already set it)
# HTML/PHP: no cache
<FilesMatch "\.(html?|php)$">
Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
Header setifempty Pragma "no-cache"
Header setifempty Expires "0"
</FilesMatch>
# version.js: always non-cacheable
# version.js: never cache
<FilesMatch "^js/version\.js$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
# Unversioned JS/CSS: 1 hour
# JS/CSS: long cache if ?v= present, else 1h
<FilesMatch "\.(?:m?js|css)$">
Header set Cache-Control "public, max-age=3600, must-revalidate" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
Header set Cache-Control "public, max-age=3600, must-revalidate" env=!IS_VER
</FilesMatch>
# Unversioned static (images/fonts): 7 days
# Images/fonts: long cache if ?v= present, else 7d
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
Header set Cache-Control "public, max-age=604800" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
Header set Cache-Control "public, max-age=604800" env=!IS_VER
</FilesMatch>
# --- Versioned assets (?v=...) : 1 year + immutable (override anything else) ---
<IfModule mod_headers.c>
<FilesMatch "\.(?:m?js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
# Only when query string has v=
Header unset Cache-Control "expr=%{QUERY_STRING} =~ /(^|&)v=/"
Header unset Expires "expr=%{QUERY_STRING} =~ /(^|&)v=/"
Header set Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/"
</FilesMatch>
</IfModule>
</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>

View File

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

View File

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

View File

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

View 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();

View File

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

View File

@@ -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']);
}

View File

@@ -0,0 +1,28 @@
<?php
// Fast ACL-aware peek for tree icons/chevrons
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
$username = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
'folderOnly' => $_SESSION['folderOnly'] ?? null,
'readOnly' => $_SESSION['readOnly'] ?? null,
];
@session_write_close();
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
echo json_encode(FolderController::stats($folder, $username, $perms), JSON_UNESCAPED_SLASHES);

View File

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

View 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']);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/assets/logo-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/assets/logo-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

BIN
public/assets/logo-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
public/assets/logo-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
public/assets/logo-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

BIN
public/assets/logo-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/assets/logo-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -3,16 +3,24 @@
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
<meta name="theme-color" content="#0b5ed7">
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
<style id="pretheme-css">
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
</style>
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="theme-color" content="#0b5ed7"><meta name="color-scheme" content="light dark">
<!-- 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}}">
<!-- Critical CSS -->
<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}}">
@@ -27,8 +35,8 @@
<!-- App entry -->
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
</head>
</head>
<body>
<div id="appRoot" style="visibility:hidden">
<header class="header-container">
@@ -73,7 +81,7 @@
<!-- Trash items will be loaded here -->
</div>
<div style="text-align: right;">
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected" style="display: none;">Restore
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected">Restore
Selected</button>
<button id="restoreAllBtn" class="btn btn-secondary" data-i18n-key="restore_all">Restore All</button>
<button id="deleteTrashSelectedBtn" class="btn btn-warning" data-i18n-key="delete_selected_trash">Delete
@@ -244,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>
@@ -266,14 +277,24 @@
</div>
</div>
<div id="folderHelpTooltip" class="folder-help-tooltip"
style="display: none; position: absolute; top: 50px; right: 15px; background: #fff; border: 1px solid #ccc; padding: 10px; z-index: 1000; box-shadow: 2px 2px 6px rgba(0,0,0,0.2);">
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;">
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li>
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li>
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a
subfolder.</li>
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click
the appropriate button.</li>
style="display:none;position:absolute;top:50px;right:15px;background:#fff;border:1px solid #ccc;padding:10px;z-index:1000;box-shadow:2px 2px 6px rgba(0,0,0,0.2);border-radius:8px;max-width:320px;line-height:1.35;">
<style>
/* Dark mode polish */
body.dark-mode #folderHelpTooltip {
background:#2c2c2c; border-color:#555; color:#e8e8e8; box-shadow:2px 2px 10px rgba(0,0,0,.5);
}
#folderHelpTooltip .folder-help-list { margin:0; padding-left:18px; }
#folderHelpTooltip .folder-help-list li { margin:6px 0; }
</style>
<ul class="folder-help-list">
<li data-i18n-key="folder_help_click_view">Click a folder in the tree to view its files.</li>
<li data-i18n-key="folder_help_expand_chevrons">Use chevrons to expand/collapse. Locked folders (padlock) can expand but cant be opened.</li>
<li data-i18n-key="folder_help_context_menu">Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.</li>
<li data-i18n-key="folder_help_drag_drop">Drag a folder onto another folder <em>or</em> a breadcrumb to move it.</li>
<li data-i18n-key="folder_help_load_more">For long lists, click “Load more” to fetch the next page of folders.</li>
<li data-i18n-key="folder_help_last_folder">Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.</li>
<li data-i18n-key="folder_help_breadcrumbs">Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.</li>
<li data-i18n-key="folder_help_permissions">Buttons enable/disable based on your permissions for the selected folder.</li>
</ul>
</div>
</div>
@@ -344,6 +365,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 -->
@@ -452,6 +477,26 @@
</form>
</div>
</div>
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
<button type="button" class="mi" data-action="create_file" data-when="always"><i class="material-icons">note_add</i><span>Create file</span></button>
<div class="sep" data-when="always"></div>
<button type="button" class="mi" data-action="delete_selected" data-when="any"><i class="material-icons">delete</i><span>Delete selected</span></button>
<button type="button" class="mi" data-action="copy_selected" data-when="any"><i class="material-icons">content_copy</i><span>Copy selected</span></button>
<button type="button" class="mi" data-action="move_selected" data-when="any"><i class="material-icons">drive_file_move</i><span>Move selected</span></button>
<button type="button" class="mi" data-action="download_zip" data-when="any"><i class="material-icons">archive</i><span>Download as ZIP</span></button>
<button type="button" class="mi" data-action="extract_zip" data-when="zip"><i class="material-icons">unarchive</i><span>Extract ZIP</span></button>
<div class="sep" data-when="any"></div>
<button type="button" class="mi" data-action="tag_selected" data-when="many"><i class="material-icons">sell</i><span>Tag selected</span></button>
<button type="button" class="mi" data-action="preview" data-when="one"><i class="material-icons">visibility</i><span>Preview</span></button>
<button type="button" class="mi" data-action="edit" data-when="can-edit"><i class="material-icons">edit</i><span>Edit</span></button>
<button type="button" class="mi" data-action="rename" data-when="one"><i class="material-icons">drive_file_rename_outline</i><span>Rename</span></button>
<button type="button" class="mi" data-action="tag_file" data-when="one"><i class="material-icons">sell</i><span>Tag file</span></button>
</div>
<div id="removeUserModal" class="modal" style="display:none;">
<div class="modal-content">
<h3 data-i18n-key="remove_user_title">Remove User</h3>
@@ -483,7 +528,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">&times;</span>
</div>
<div class="modal-body">
<!-- we will MOVE #uploadCard into here while open -->
<div id="uploadModalBody"></div>
</div>
</div>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -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) {
const fileListArea = document.getElementById('fileList');
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 }));
}
});
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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 nameinput modal, show the spinner modal
// b) Hide the nameinput 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 browsers 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 098% 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;

View File

@@ -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 rows 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).');
}
}

View File

@@ -65,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);
});
}
@@ -109,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);
}
@@ -134,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 theres 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"}">&times;</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() : "";
@@ -196,7 +519,7 @@ 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();
@@ -204,6 +527,11 @@ export function editFile(fileName, folder) {
const folderUsed = folder || window.currentFolder || "root";
const fileUrl = buildPreviewUrl(folderUsed, fileName);
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 {
@@ -316,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
@@ -360,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);
@@ -370,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 => {

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -302,7 +302,36 @@ 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.",
"load_more": "Load more",
"loading": "Loading...",
"no_access": "You do not have access to this resource.",
"please_select_valid_folder": "Please select a valid folder.",
"folder_help_click_view": "Click a folder in the tree to view its files.",
"folder_help_expand_chevrons": "Use chevrons to expand/collapse. Locked folders (padlock) can expand but cant be opened.",
"folder_help_context_menu": "Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.",
"folder_help_drag_drop": "Drag a folder onto another folder or a breadcrumb to move it.",
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
"load_more_folders": "Load More Folders"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -403,39 +403,91 @@ function bindDarkMode() {
function applySiteConfig(cfg, { phase = 'final' } = {}) {
try {
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
// Always keep <title> correct early (no visual flicker)
document.title = title;
// --- Header logo (branding) in BOTH phases ---
try {
const branding = (cfg && cfg.branding) ? cfg.branding : {};
const customLogoUrl = branding.customLogoUrl || "";
const logoImg = document.querySelector('.header-logo img');
if (logoImg) {
if (customLogoUrl) {
logoImg.setAttribute('src', customLogoUrl);
logoImg.setAttribute('alt', 'Site logo');
} else {
// fall back to default FileRise logo
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
logoImg.setAttribute('alt', 'FileRise');
}
}
} catch (e) {
// non-fatal; ignore branding issues
}
// --- Header colors (branding) in BOTH phases ---
try {
const branding = (cfg && cfg.branding) ? cfg.branding : {};
const root = document.documentElement;
const light = branding.headerBgLight || '';
const dark = branding.headerBgDark || '';
if (light) root.style.setProperty('--header-bg-light', light);
else root.style.removeProperty('--header-bg-light');
if (dark) root.style.setProperty('--header-bg-dark', dark);
else root.style.removeProperty('--header-bg-dark');
} catch (e) {
// non-fatal
}
// --- Login options (apply in BOTH phases so login page is correct) ---
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
const disableForm = !!lo.disableFormLogin;
const disableOIDC = !!lo.disableOIDCLogin;
const disableBasic = !!lo.disableBasicAuth;
const row = $('#loginForm');
if (row) {
if (disableForm) {
row.setAttribute('hidden', '');
row.style.display = ''; // don't leave display:none lying around
// be tolerant to key variants just in case
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const showForm = !disableForm;
const showOIDC = !disableOIDC;
const showBasic = !disableBasic;
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
const authForm = $('#authForm'); // inner username/password form
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
if (loginWrap) {
const anyMethod = showForm || showOIDC || showBasic;
if (anyMethod) {
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
loginWrap.style.display = ''; // let CSS decide
} else {
row.removeAttribute('hidden');
row.style.display = '';
loginWrap.setAttribute('hidden', '');
loginWrap.style.display = '';
}
}
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
// 2) Toggle the pieces inside the wrapper
if (authForm) authForm.style.display = showForm ? '' : 'none';
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
if (basic) basic.style.display = disableBasic ? 'none' : '';
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
if (phase === 'final') {
const h1 = document.querySelector('.header-title h1');
if (h1) {
// prevent i18n or legacy from overwriting it
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
if (h1.textContent !== title) h1.textContent = title;
// lock it so late code can't stomp it
if (!h1.__titleLock) {
const mo = new MutationObserver(() => {
@@ -1037,6 +1089,21 @@ function bindDarkMode() {
if (login) login.style.display = '';
// …wire stuff…
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
// Auto-SSO if OIDC is the only enabled method (add ?noauto=1 to skip)
(() => {
const lo = (window.__FR_SITE_CFG__ && window.__FR_SITE_CFG__.loginOptions) || {};
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
const onlyOIDC = disableForm && disableBasic && !disableOIDC;
const qp = new URLSearchParams(location.search);
if (onlyOIDC && qp.get('noauto') !== '1') {
const btn = document.getElementById('oidcLoginBtn');
if (btn) setTimeout(() => btn.click(), 250);
}
})();
await revealAppAndHideOverlay();
const hb = document.querySelector('.header-buttons');
if (hb) hb.style.visibility = 'hidden';
@@ -1057,4 +1124,52 @@ function bindDarkMode() {
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(() => { });
});
}
})();

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

6
public/sw.js Normal file
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 623 KiB

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 KiB

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 KiB

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 608 KiB

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 KiB

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

After

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

After

Width:  |  Height:  |  Size: 666 KiB

54
scripts/manual-sync.sh Normal file
View 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 (dont fail the whole script if reload isnt 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
View 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 FileModels validation by calling it but not keeping the zip; well 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);
}

View File

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

View File

@@ -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']);

View File

@@ -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();
@@ -590,25 +643,137 @@ public function deleteFiles()
} finally { $this->_jsonEnd(); }
}
/**
* Stream a file with proper HTTP Range support so HTML5 video/audio can seek.
*
* @param string $fullPath Absolute filesystem path
* @param string $downloadName Name shown in Content-Disposition
* @param string $mimeType MIME type (from FileModel::getDownloadInfo)
* @param bool $inline true => inline, false => attachment
*/
private function streamFileWithRange(string $fullPath, string $downloadName, string $mimeType, bool $inline): void
{
if (!is_file($fullPath) || !is_readable($fullPath)) {
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'File not found']);
exit;
}
$size = (int)@filesize($fullPath);
$start = 0;
$end = $size > 0 ? $size - 1 : 0;
if ($size < 0) {
$size = 0;
$end = 0;
}
// Close session + disable output buffering for streaming
if (session_status() === PHP_SESSION_ACTIVE) {
@session_write_close();
}
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
}
@ini_set('zlib.output_compression', '0');
@ini_set('output_buffering', 'off');
while (ob_get_level() > 0) {
@ob_end_clean();
}
$disposition = $inline ? 'inline' : 'attachment';
$mime = $mimeType ?: 'application/octet-stream';
header('X-Content-Type-Options: nosniff');
header('Accept-Ranges: bytes');
header("Content-Type: {$mime}");
header("Content-Disposition: {$disposition}; filename=\"" . basename($downloadName) . "\"");
// Handle HTTP Range header (single range)
$length = $size;
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=\s*(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)) {
if ($m[1] !== '') {
$start = (int)$m[1];
}
if ($m[2] !== '') {
$end = (int)$m[2];
}
// clamp to file size
if ($start < 0) $start = 0;
if ($end < $start) $end = $start;
if ($end >= $size) $end = $size - 1;
$length = $end - $start + 1;
http_response_code(206);
header("Content-Range: bytes {$start}-{$end}/{$size}");
header("Content-Length: {$length}");
} else {
// no range => full file
http_response_code(200);
if ($size > 0) {
header("Content-Length: {$size}");
}
}
$fp = @fopen($fullPath, 'rb');
if ($fp === false) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Unable to open file.']);
exit;
}
if ($start > 0) {
@fseek($fp, $start);
}
$bytesToSend = $length;
$chunkSize = 8192;
while ($bytesToSend > 0 && !feof($fp)) {
$readSize = ($bytesToSend > $chunkSize) ? $chunkSize : $bytesToSend;
$buffer = fread($fp, $readSize);
if ($buffer === false) {
break;
}
echo $buffer;
flush();
$bytesToSend -= strlen($buffer);
if (connection_aborted()) {
break;
}
}
fclose($fp);
exit;
}
public function downloadFile()
{
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
header('Content-Type: application/json');
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Unauthorized"]);
exit;
}
$file = isset($_GET['file']) ? basename($_GET['file']) : '';
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
$file = isset($_GET['file']) ? basename((string)$_GET['file']) : '';
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
$inlineParam = isset($_GET['inline']) && (string)$_GET['inline'] === '1';
if (!preg_match(REGEX_FILE_NAME, $file)) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Invalid file name."]);
exit;
}
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
@@ -628,6 +793,7 @@ public function deleteFiles()
if (!$fullView && !$ownGrant) {
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Forbidden: no view access to this folder."]);
exit;
}
@@ -637,6 +803,7 @@ public function deleteFiles()
$meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) {
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
exit;
}
@@ -644,99 +811,235 @@ public function deleteFiles()
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
if (isset($downloadInfo['error'])) {
http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400);
http_response_code(in_array($downloadInfo['error'], ["File not found.", "Access forbidden."]) ? 404 : 400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => $downloadInfo['error']]);
exit;
}
$realFilePath = $downloadInfo['filePath'];
$mimeType = $downloadInfo['mimeType'];
header("Content-Type: " . $mimeType);
// Decide inline vs attachment:
// - if ?inline=1 => always inline (used by filePreview.js)
// - else keep your old behavior: images inline, everything else attachment
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
$inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico'];
if (in_array($ext, $inlineImageTypes, true)) {
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
} else {
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
}
header('Content-Length: ' . filesize($realFilePath));
readfile($realFilePath);
exit;
$inline = $inlineParam || in_array($ext, $inlineImageTypes, true);
// Stream with proper Range support for video/audio seeking
$this->streamFileWithRange($realFilePath, basename($realFilePath), $mimeType, $inline);
}
public function 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()
{

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
<?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 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";
}
private function validFolder($f): bool {
if ($f === 'root') return true;
// Validate per-segment against your REGEX_FOLDER_NAME
$parts = array_filter(explode('/', (string)$f), fn($p) => $p !== '');
if (!$parts) return false;
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) return false;
}
return true;
}
/** “View” means read OR read_own */
private function canViewFolder(string $folder, string $username): bool {
$perms = loadUserPermissions($username) ?: [];
return ACL::canRead($username, $perms, $folder)
|| ACL::canReadOwn($username, $perms, $folder);
}
/** POST /api/media/updateProgress.php */
public function updateProgress(): void {
$this->jsonStart();
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']) ? (float)$d['seconds'] : 0.0;
$duration = isset($d['duration']) ? (float)$d['duration'] : null;
$completed = isset($d['completed']) ? (bool)$d['completed'] : null;
$clear = !empty($d['clear']);
if (!$this->validFolder($folder) || !$this->validFile($file)) {
$this->out(['error'=>'Invalid folder/file'], 400); return;
}
if (!$this->canViewFolder($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->canViewFolder($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;
}
// Soft-fail for restricted users: avoid noisy console 403s
if (!$this->canViewFolder($folder, $u)) {
$this->out(['map' => []]); // 200 OK, no leakage
return;
}
$map = MediaModel::getFolderMap($u, $folder);
$this->out(['map'=>$map]);
} catch (Throwable $e) {
error_log('MediaController::getViewedMap: '.$e->getMessage());
$this->out(['error'=>'Internal server error'], 500);
} finally { $this->jsonEnd(); }
}
}

View 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 (well always set edit=false for these)
private const OO_NEVER_EDIT = ['pdf'];
// (Optional) More view-only types you can enable if you like
private const OO_VIEW_ONLY_EXTRAS = [
'djvu','xps','oxps','epub','fb2','pages','hwp','hwpx',
'vsdx','vsdm','vssx','vssm','vstx','vstm'
];
/** Resolve effective secret: constants override adminConfig */
private function effectiveSecret(): string
{
$cfg = AdminModel::getConfig();
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
if (defined('ONLYOFFICE_JWT_SECRET') && ONLYOFFICE_JWT_SECRET !== '') {
return (string)ONLYOFFICE_JWT_SECRET;
}
return (string)($oo['jwtSecret'] ?? '');
}
// --- lightweight logger ------------------------------------------------------
private const OO_LOG_PATH = '/var/www/users/onlyoffice-cb.debug';
private function ooDebug(): bool
{
// Enable verbose logging by either constant or env var
if (defined('ONLYOFFICE_DEBUG') && ONLYOFFICE_DEBUG) return true;
return getenv('ONLYOFFICE_DEBUG') === '1';
}
/**
* @param 'error'|'warn'|'info'|'debug' $level
*/
private function ooLog(string $level, string $msg): void
{
$level = strtolower($level);
$line = '[OO-CB][' . strtoupper($level) . '] ' . $msg;
// Only emit to Apache on errors (keeps logs clean)
if ($level === 'error') {
error_log($line);
}
// If debug mode is on, mirror all levels to a local file
if ($this->ooDebug()) {
@file_put_contents(self::OO_LOG_PATH, '[' . date('c') . '] ' . $line . "\n", FILE_APPEND);
}
}
/** Resolve effective docs origin (http/https root of OO Docs server) */
private function effectiveDocsOrigin(): string
{
$cfg = AdminModel::getConfig();
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
if (defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== '') {
return (string)ONLYOFFICE_DOCS_ORIGIN;
}
if (!empty($oo['docsOrigin'])) return (string)$oo['docsOrigin'];
$env = getenv('ONLYOFFICE_DOCS_ORIGIN');
return $env ? (string)$env : '';
}
/** Resolve effective enabled flag (constants override adminConfig) */
private function effectiveEnabled(): bool
{
$cfg = AdminModel::getConfig();
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
if (defined('ONLYOFFICE_ENABLED')) return (bool)ONLYOFFICE_ENABLED;
return !empty($oo['enabled']);
}
/** Optional explicit public origin; else infer from BASE_URL / request */
private function effectivePublicOrigin(): string
{
$cfg = AdminModel::getConfig();
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
if (defined('ONLYOFFICE_PUBLIC_ORIGIN') && ONLYOFFICE_PUBLIC_ORIGIN !== '') {
return (string)ONLYOFFICE_PUBLIC_ORIGIN;
}
if (!empty($oo['publicOrigin'])) return (string)$oo['publicOrigin'];
// Try BASE_URL if it isn't a placeholder
if (defined('BASE_URL') && strpos((string)BASE_URL, 'yourwebsite') === false) {
$u = parse_url((string)BASE_URL);
if (!empty($u['scheme']) && !empty($u['host'])) {
return $u['scheme'].'://'.$u['host'].(isset($u['port'])?':'.$u['port']:'');
}
}
// Fallback to request (proxy aware)
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO']
?? ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http');
$host = $_SERVER['HTTP_X_FORWARDED_HOST'] ?? ($_SERVER['HTTP_HOST'] ?? 'localhost');
return $proto.'://'.$host;
}
/** base64url encode/decode helpers */
private function b64uDec(string $s)
{
$s = strtr($s, '-_', '+/');
$pad = strlen($s) % 4;
if ($pad) $s .= str_repeat('=', 4 - $pad);
return base64_decode($s, true);
}
private function b64uEnc(string $s): string
{
return rtrim(strtr(base64_encode($s), '+/','-_'), '=');
}
/** GET /api/onlyoffice/status.php */
public function status(): void
{
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$enabled = $this->effectiveEnabled();
$docsOrig = $this->effectiveDocsOrigin();
$secret = $this->effectiveSecret();
// Must have docs origin and secret to actually function
$enabled = $enabled && ($docsOrig !== '') && ($secret !== '');
$exts = self::OO_SUPPORTED_EXTS;
$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 doesnt 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);
}
}

View File

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

View File

@@ -649,8 +649,16 @@ class UserController
exit;
}
// Assuming /uploads maps to UPLOAD_DIR publicly
$url = '/uploads/profile_pics/' . $filename;
$fsPath = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics/' . $filename;
// Remove the filesystem root (PROJECT_ROOT) so we get a web-relative path
$root = rtrim(PROJECT_ROOT, '/\\');
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
// Ensure it starts with /
if ($url === '' || $url[0] !== '/') {
$url = '/' . $url;
}
$result = UserModel::setProfilePicture($_SESSION['username'], $url);
if (!($result['success'] ?? false)) {
@@ -667,6 +675,76 @@ class UserController
exit;
}
/**
* Upload branding logo (Pro-only; admin, CSRF).
* Reuses the profile_pics directory but does NOT change the user's avatar.
*/
public function uploadBrandLogo()
{
self::jsonHeaders();
// Auth, admin & CSRF
self::requireAuth();
self::requireAdmin();
self::requireCsrf();
if (empty($_FILES['brand_logo']) || $_FILES['brand_logo']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
exit;
}
$file = $_FILES['brand_logo'];
// Validate MIME & size (same rules as uploadPicture)
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!isset($allowed[$mime])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid file type']);
exit;
}
if ($file['size'] > 2 * 1024 * 1024) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'File too large']);
exit;
}
// Destination: reuse profile_pics directory
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
exit;
}
$ext = $allowed[$mime];
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username'] ?? 'logo');
$filename = 'branding_' . $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$dest = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
exit;
}
$fsPath = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics/' . $filename;
// Remove the filesystem root (PROJECT_ROOT) so we get a web-relative path
$root = rtrim(PROJECT_ROOT, '/\\');
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
// Ensure it starts with /
if ($url === '' || $url[0] !== '/') {
$url = '/' . $url;
}
echo json_encode(['success' => true, 'url' => $url]);
exit;
}
public function siteConfig(): void
{
header('Content-Type: application/json');

View File

@@ -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');

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

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

View File

@@ -62,27 +62,91 @@ 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 : '';
}
/** Allow logo URLs that are either site-relative (/uploads/…) or http(s). */
private static function sanitizeLogoUrl($url): string
{
$url = trim((string)$url);
if ($url === '') return '';
// 1) Site-relative like "/uploads/profile_pics/branding_foo.png"
if ($url[0] === '/') {
// Strip CRLF just in case
$url = preg_replace('~[\r\n]+~', '', $url);
// Dont allow sneaky schemes embedded in a relative path
if (strpos($url, '://') !== false) {
return '';
}
return $url;
}
// 2) Fallback to plain http(s) validation
return self::sanitizeHttpUrl($url);
}
public static function buildPublicSubset(array $config): array
{
$public = [
'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'] ?? ''),
],
'branding' => [
'customLogoUrl' => self::sanitizeLogoUrl(
$config['branding']['customLogoUrl'] ?? ''
),
'headerBgLight' => self::sanitizeColorHex(
$config['branding']['headerBgLight'] ?? ''
),
'headerBgDark' => self::sanitizeColorHex(
$config['branding']['headerBgDark'] ?? ''
),
],
];
// NEW: include ONLYOFFICE minimal public flag
$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 +237,52 @@ 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;
}
// Branding (Pro-only). Normalize and only persist when Pro is active.
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
$configUpdate['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
];
} else {
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
$configUpdate['branding']['customLogoUrl'] = $logo;
$configUpdate['branding']['headerBgLight'] = $light;
$configUpdate['branding']['headerBgDark'] = $dark;
} else {
// Free mode: always clear branding customizations
$configUpdate['branding']['customLogoUrl'] = '';
$configUpdate['branding']['headerBgLight'] = '';
$configUpdate['branding']['headerBgDark'] = '';
}
}
// Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
if ($plainTextConfig === false) {
@@ -213,6 +323,18 @@ class AdminModel
return ["success" => "Configuration updated successfully."];
}
private static function sanitizeColorHex($value): string
{
$value = trim((string)$value);
if ($value === '') return '';
// allow #RGB or #RRGGBB
if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value)) {
return strtoupper($value);
}
return '';
}
/**
* Retrieves the current configuration.
*
@@ -301,6 +423,38 @@ 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'] ?? '');
}
// Branding
if (!isset($config['branding']) || !is_array($config['branding'])) {
$config['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
];
} else {
$config['branding']['customLogoUrl'] = self::sanitizeLogoUrl(
$config['branding']['customLogoUrl'] ?? ''
);
$config['branding']['headerBgLight'] = self::sanitizeColorHex(
$config['branding']['headerBgLight'] ?? ''
);
$config['branding']['headerBgDark'] = self::sanitizeColorHex(
$config['branding']['headerBgDark'] ?? ''
);
}
return $config;
}
@@ -320,7 +474,17 @@ 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' => '',
],
'branding' => [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
],
];
}
}
}

View File

@@ -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
View 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];
}
}

View File

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

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

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

View File

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

View File

@@ -72,6 +72,23 @@ for d in uploads users metadata; do
chmod 775 "${tgt}"
done
# 2.4) Sync FileRise Pro public endpoints from persistent bundle
BUNDLE_PRO_PUBLIC="/var/www/users/pro/public/api/pro"
LIVE_PRO_PUBLIC="/var/www/public/api/pro"
if [ -d "${BUNDLE_PRO_PUBLIC}" ]; then
echo "[startup] Syncing FileRise Pro public endpoints..."
mkdir -p "${LIVE_PRO_PUBLIC}"
# Copy files from bundle to live api/pro (overwrite for upgrades)
cp -R "${BUNDLE_PRO_PUBLIC}/." "${LIVE_PRO_PUBLIC}/" || echo "[startup] Pro sync copy failed (continuing)"
# Normalize ownership/permissions
chown -R www-data:www-data "${LIVE_PRO_PUBLIC}" || echo "[startup] chown api/pro failed (continuing)"
find "${LIVE_PRO_PUBLIC}" -type d -exec chmod 755 {} \; 2>/dev/null || true
find "${LIVE_PRO_PUBLIC}" -type f -exec chmod 644 {} \; 2>/dev/null || true
fi
# 3) Ensure PHP conf dir & set upload limits
mkdir -p /etc/php/8.3/apache2/conf.d
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then