Compare commits

...

95 Commits

Author SHA1 Message Date
github-actions[bot]
08a84419f0 chore(release): set APP_VERSION to v1.9.12 [skip ci] 2025-11-19 07:48:18 +00:00
Ryan
49d3588322 release(v1.9.12): feat(pro-acl): add user groups and group-aware ACL 2025-11-19 02:48:06 -05:00
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
github-actions[bot]
77a94ecd85 chore(release): set APP_VERSION to v1.7.5 [skip ci] 2025-11-02 04:56:35 +00:00
Ryan
699873848e release(v1.7.5): retrigger CI bump ensure up to date 2025-11-02 00:56:24 -04:00
Ryan
9cb12c11a6 release(v1.7.5): retrigger CI bump (no code changes) 2025-11-02 00:49:36 -04:00
Ryan
c08876380b release(v1.7.5): retrigger CI bump; chore(ci): update bump workflow 2025-11-02 00:44:29 -04:00
Ryan
5b824888cb release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50) 2025-11-02 00:32:04 -04:00
Ryan
b7d7f7c3ce release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50) 2025-11-02 00:32:03 -04:00
github-actions[bot]
e509b7ac9c chore(release): set APP_VERSION to v1.7.4 [skip ci] 2025-10-31 22:18:01 +00:00
Ryan
947255d94c release(v1.7.4): login hint replace toast + fix unauth boot 2025-10-31 18:17:52 -04:00
Ryan
55d44ef880 release(1.7.4): login hint replaced toast + fix unauth boot 2025-10-31 18:11:08 -04:00
github-actions[bot]
ad76e37ad5 chore(release): set APP_VERSION to v1.7.3 [skip ci] 2025-10-31 21:34:41 +00:00
Ryan
d664a2f5d8 release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth 2025-10-31 17:34:25 -04:00
github-actions[bot]
a18a8df7af chore(release): set APP_VERSION to v1.7.2 [skip ci] 2025-10-29 20:54:31 +00:00
Ryan
8cf5a34ae9 release(v1.7.2): harden asset stamping & CI verification 2025-10-29 16:54:22 -04:00
github-actions[bot]
55d5656139 chore(release): set APP_VERSION to v1.7.1 [skip ci] 2025-10-29 20:19:45 +00:00
Ryan
04be05ad1e release(v1.7.1): stamp-assets.sh invoke via bash 2025-10-29 16:19:35 -04:00
github-actions[bot]
0469d183de chore(release): set APP_VERSION to v1.7.0 [skip ci] 2025-10-29 20:07:32 +00:00
Ryan
b1de8679e0 release(v1.7.0): asset cache-busting pipeline, public siteConfig cache, JS core split, and caching/security polish 2025-10-29 16:07:22 -04:00
github-actions[bot]
f4f7ec0dca chore(release): set APP_VERSION and stamp assets to v1.6.11 [skip ci] 2025-10-28 07:22:04 +00:00
Ryan
5a7c4704d0 release(v1.6.11) fix(ui/dragAndDrop) restore floating zones toggle click action 2025-10-28 03:21:52 -04:00
Ryan
8b880738d6 chore(codeql): move config to repo root for default setup 2025-10-28 02:54:17 -04:00
Ryan
06c732971f ci(release): fix lint + harden release workflow 2025-10-28 02:44:13 -04:00
github-actions[bot]
ab75381acb chore(release): set APP_VERSION and stamp assets to v1.6.10 [skip ci] 2025-10-28 06:12:04 +00:00
Ryan
b1bd903072 release(v1.6.10): self-host ReDoc, gate sidebar toggle on auth, and enrich release workflow 2025-10-28 02:11:54 -04:00
Ryan
ab327acc8a chore(icons): remove material-symbols-rounded 2025-10-27 06:01:07 -04:00
Ryan
2e98ceee4c docs: move THIRD_PARTY.md to repo root 2025-10-27 05:55:05 -04:00
117 changed files with 20918 additions and 8314 deletions

View File

@@ -1,12 +0,0 @@
---
name: "FileRise CodeQL config"
paths:
- "public/js"
- "api"
paths-ignore:
- "public/vendor/**"
- "public/css/vendor/**"
- "public/fonts/**"
- "public/**/*.min.js"
- "public/**/*.min.css"
- "public/**/*.map"

View File

@@ -2,14 +2,18 @@
name: Release on version.js update name: Release on version.js update
on: on:
push:
branches:
- master
paths:
- public/js/version.js
workflow_run: workflow_run:
workflows: "Bump version and sync Changelog to Docker Repo" workflows: ["Bump version and sync Changelog to Docker Repo"]
types: completed 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: permissions:
contents: write contents: write
@@ -17,35 +21,70 @@ permissions:
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: |
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch'
concurrency: concurrency:
group: release-${{ github.ref }}-${{ github.sha }} group: release-${{ github.event_name }}-${{ github.run_id }}
cancel-in-progress: false cancel-in-progress: false
steps: 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 uses: actions/checkout@v4
with: with:
ref: ${{ steps.pickref.outputs.ref }}
fetch-depth: 0 fetch-depth: 0
persist-credentials: false
- name: Read version from version.js - name: Determine version
id: ver id: ver
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
VER=$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/") if [[ -n "${{ github.event.inputs.version || '' }}" ]]; then
if [[ -z "$VER" ]]; then VER="${{ github.event.inputs.version }}"
echo "Could not parse APP_VERSION from version.js" >&2 else
exit 1 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 fi
echo "version=$VER" >> "$GITHUB_OUTPUT" echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "Parsed version: $VER" echo "Detected version: $VER"
- name: Skip if tag already exists - name: Skip if tag already exists
id: tagcheck id: tagcheck
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
git fetch --tags --quiet
if git rev-parse -q --verify "refs/tags/${{ steps.ver.outputs.version }}" >/dev/null; then if git rev-parse -q --verify "refs/tags/${{ steps.ver.outputs.version }}" >/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT" echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${{ steps.ver.outputs.version }} already exists. Skipping release." echo "Tag ${{ steps.ver.outputs.version }} already exists. Skipping release."
@@ -53,7 +92,109 @@ jobs:
echo "exists=false" >> "$GITHUB_OUTPUT" echo "exists=false" >> "$GITHUB_OUTPUT"
fi fi
- name: Prepare release notes from CHANGELOG.md (optional) - name: Prepare stamp script
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
set -euo pipefail
sed -i 's/\r$//' scripts/stamp-assets.sh || true
chmod +x scripts/stamp-assets.sh
- name: Build stamped staging tree
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
rm -rf staging
rsync -a \
--exclude '.git' --exclude '.github' \
--exclude 'resources' \
--exclude '.dockerignore' --exclude '.gitattributes' --exclude '.gitignore' \
./ staging/
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/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 "Unreplaced placeholders found in staging." >&2
exit 1
fi
echo "OK: No unreplaced placeholders."
- name: Zip artifact (includes vendor/)
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
(cd staging && zip -r "../FileRise-${VER}.zip" . >/dev/null)
- name: Compute SHA-256
if: steps.tagcheck.outputs.exists == 'false'
id: sum
shell: bash
run: |
set -euo pipefail
ZIP="FileRise-${{ steps.ver.outputs.version }}.zip"
SHA=$(shasum -a 256 "$ZIP" | awk '{print $1}')
echo "$SHA $ZIP" > "${ZIP}.sha256"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "Computed SHA-256: $SHA"
- name: Extract notes from CHANGELOG (optional)
if: steps.tagcheck.outputs.exists == 'false' if: steps.tagcheck.outputs.exists == 'false'
id: notes id: notes
shell: bash shell: bash
@@ -66,45 +207,65 @@ jobs:
/^## / && !found {found=1} /^## / && !found {found=1}
found && /^---$/ {exit} found && /^---$/ {exit}
found {print} found {print}
' CHANGELOG.md > RELEASE_BODY.md || true ' CHANGELOG.md > CHANGELOG_SNIPPET.md || true
sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' CHANGELOG_SNIPPET.md || true
# Trim trailing blank lines if [[ -s CHANGELOG_SNIPPET.md ]]; then
sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' RELEASE_BODY.md || true NOTES_PATH="CHANGELOG_SNIPPET.md"
if [[ -s RELEASE_BODY.md ]]; then
NOTES_PATH="RELEASE_BODY.md"
fi fi
fi fi
echo "path=$NOTES_PATH" >> "$GITHUB_OUTPUT" echo "path=$NOTES_PATH" >> "$GITHUB_OUTPUT"
- name: (optional) Build archive to attach - name: Compute previous tag (for Full Changelog link)
if: steps.tagcheck.outputs.exists == 'false'
id: prev
shell: bash
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
PREV=$(git tag --list "v*" --sort=-v:refname | grep -v -F "$VER" | head -n1 || true)
if [[ -z "$PREV" ]]; then
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
fi
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
echo "Previous tag/baseline: $PREV"
- name: Build release body
if: steps.tagcheck.outputs.exists == 'false' if: steps.tagcheck.outputs.exists == 'false'
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
zip -r "FileRise-${{ steps.ver.outputs.version }}.zip" public/ README.md LICENSE >/dev/null || true VER="${{ steps.ver.outputs.version }}"
PREV="${{ steps.prev.outputs.prev }}"
REPO="${GITHUB_REPOSITORY}"
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
cat CHANGELOG_SNIPPET.md
echo
fi
echo "## ${VER}"
echo "### Full Changelog"
echo "[${PREV} → ${VER}](${COMPARE_URL})"
echo
echo "### SHA-256 (zip)"
echo '```'
echo "${SHA} ${ZIP}"
echo '```'
} > RELEASE_BODY.md
sed -n '1,200p' RELEASE_BODY.md
# Path A: we have extracted notes -> use body_path - name: Create GitHub Release
- name: Create GitHub Release (with CHANGELOG snippet) if: steps.tagcheck.outputs.exists == 'false'
if: steps.tagcheck.outputs.exists == 'false' && steps.notes.outputs.path != ''
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ steps.ver.outputs.version }} tag_name: ${{ steps.ver.outputs.version }}
target_commitish: ${{ github.sha }} target_commitish: ${{ steps.pickref.outputs.ref }}
name: ${{ steps.ver.outputs.version }} name: ${{ steps.ver.outputs.version }}
body_path: ${{ steps.notes.outputs.path }} body_path: RELEASE_BODY.md
generate_release_notes: false generate_release_notes: false
files: | files: |
FileRise-${{ steps.ver.outputs.version }}.zip FileRise-${{ steps.ver.outputs.version }}.zip
FileRise-${{ steps.ver.outputs.version }}.zip.sha256
# Path B: no notes -> let GitHub auto-generate from commits
- name: Create GitHub Release (auto notes)
if: steps.tagcheck.outputs.exists == 'false' && steps.notes.outputs.path == ''
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.version }}
target_commitish: ${{ github.sha }}
name: ${{ steps.ver.outputs.version }}
generate_release_notes: true
files: |
FileRise-${{ steps.ver.outputs.version }}.zip

View File

@@ -5,18 +5,25 @@ on:
push: push:
paths: paths:
- "CHANGELOG.md" - "CHANGELOG.md"
workflow_dispatch: {}
permissions: permissions:
contents: write contents: write
concurrency:
group: bump-and-sync-${{ github.ref }}
cancel-in-progress: false
jobs: jobs:
bump_and_sync: bump_and_sync:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Checkout FileRise
uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.ref }}
- name: Extract version from commit message - name: Extract version from commit message
id: ver id: ver
@@ -32,7 +39,24 @@ jobs:
echo "No release(vX.Y.Z) tag in commit message; skipping bump." echo "No release(vX.Y.Z) tag in commit message; skipping bump."
fi fi
- name: Update public/js/version.js # Ensure we're on the branch and up to date BEFORE modifying files
- name: Ensure clean branch (no local mods), update from remote
if: steps.ver.outputs.version != ''
shell: bash
run: |
set -euo pipefail
# Be on a named branch that tracks the remote
git checkout -B "${{ github.ref_name }}" --track "origin/${{ github.ref_name }}" || git checkout -B "${{ github.ref_name }}"
# Make sure the worktree is clean
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "::error::Working tree not clean before update. Aborting."
git status --porcelain
exit 1
fi
# Update branch
git pull --rebase origin "${{ github.ref_name }}"
- name: Update public/js/version.js (source of truth)
if: steps.ver.outputs.version != '' if: steps.ver.outputs.version != ''
shell: bash shell: bash
run: | run: |
@@ -42,44 +66,19 @@ jobs:
window.APP_VERSION = '${{ steps.ver.outputs.version }}'; window.APP_VERSION = '${{ steps.ver.outputs.version }}';
EOF EOF
- name: Stamp asset cache-busters (?v=...) in HTML/CSS and {{APP_VER}} everywhere - name: Commit version.js only
if: steps.ver.outputs.version != ''
shell: bash
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}" # e.g. v1.6.9
QVER="${VER#v}" # e.g. 1.6.9
echo "Stamping ?v=${QVER} and {{APP_VER}}=${VER}"
# 1) Only stamp ?v= in HTML/CSS (avoid JS concatenation issues)
mapfile -t html_css < <(git ls-files -- 'public/*.html' 'public/**/*.html' 'public/*.css' 'public/**/*.css')
for f in "${html_css[@]}"; do
sed -E -i "s/(\?v=)[^\"'&<>\s]*/\1${QVER}/g" "$f"
sed -E -i "s/\{\{APP_VER\}\}/${VER}/g" "$f"
done
# 2) For JS, only replace the {{APP_VER}} placeholder (do NOT touch ?v=)
mapfile -t jsfiles < <(git ls-files -- 'public/*.js' 'public/**/*.js')
for f in "${jsfiles[@]}"; do
sed -E -i "s/\{\{APP_VER\}\}/${VER}/g" "$f"
done
echo "Changed files:"
git status --porcelain | awk '{print $2}' | sed 's/^/ - /'
- name: Commit version bump + stamped assets
if: steps.ver.outputs.version != '' if: steps.ver.outputs.version != ''
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add public/js/version.js public git add public/js/version.js
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No changes to commit" echo "No changes to commit"
else else
git commit -m "chore(release): set APP_VERSION and stamp assets to ${{ steps.ver.outputs.version }} [skip ci]" git commit -m "chore(release): set APP_VERSION to ${{ steps.ver.outputs.version }} [skip ci]"
git push git push origin "${{ github.ref_name }}"
fi fi
- name: Checkout filerise-docker - name: Checkout filerise-docker
@@ -89,6 +88,7 @@ jobs:
repository: error311/filerise-docker repository: error311/filerise-docker
token: ${{ secrets.PAT_TOKEN }} token: ${{ secrets.PAT_TOKEN }}
path: docker-repo path: docker-repo
fetch-depth: 0
- name: Copy CHANGELOG.md and write VERSION - name: Copy CHANGELOG.md and write VERSION
if: steps.ver.outputs.version != '' if: steps.ver.outputs.version != ''
@@ -110,6 +110,6 @@ jobs:
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No changes to commit" echo "No changes to commit"
else else
git commit -m "chore: sync CHANGELOG.md and VERSION (${{ steps.ver.outputs.version }}) from FileRise" git commit -m "chore: sync CHANGELOG.md + VERSION (${{ steps.ver.outputs.version }}) from FileRise"
git push origin main git push origin main
fi fi

File diff suppressed because it is too large Load Diff

479
README.md
View File

@@ -10,424 +10,181 @@
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-❤-red)](https://github.com/sponsors/error311) [![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) [![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. - 💾 **Selfhosted “cloud drive”** Runs anywhere with PHP (or via Docker). No external DB required.
Upload, organize, and share files or folders through a sleek, responsive web interface. - 🔐 **Granular perfolder ACLs** View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
**FileRise** is lightweight yet powerful — your personal cloud drive that you fully control. - 🔄 **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. ![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v1.9.7.png)
Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage* on a per-user, per-folder basis — enforced across the UI, API, and WebDAV.
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. > 💡 Looking for **FileRise Pro** (brandable header, Pro features, license handling)?
> Check out [filerise.net](https://filerise.net) FileRise Core stays fully opensource (MIT).
> ⚠️ **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)
--- ---
## 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. - 🚀 **Live demo:** [Demo](https://demo.filerise.net) (username: `demo` / password: `demo`)
- 📚 **Docs & Wiki:** [Wiki](https://github.com/error311/FileRise/wiki)
- 🗂️ **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. - [Features overview](https://github.com/error311/FileRise/wiki/Features)
- [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
- 🗃️ **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. - [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
- 🐳 **Docker image:** [Docker](https://github.com/error311/filerise-docker)
- 🔐 **Granular Access Control (ACL):** - 📝 **Changelog:** [Changes](https://github.com/error311/FileRise/blob/master/CHANGELOG.md)
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 ## 1. What FileRise does
[![Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://demo.filerise.net) FileRise turns a folder on your server into a **webbased file explorer** with:
**Demo credentials:** `demo` / `demo`
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. The easiest way to run FileRise is the official Docker image.
---
### 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
```bash ```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 ```text
docker run -d \ http://your-server-ip:8080
--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="please_change_this_@@" \
-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
``` ```
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`. **More Docker options (Unraid, dockercompose, env vars, reverse proxy, etc.)**
See the Docker repo: [docker repo](https://github.com/error311/filerise-docker)
**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: "please_change_this_@@"
# 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.
--- ---
### 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** **Requirements**
- PHP **8.3+** - PHP **8.3+**
- Apache (mod_php) or another web server configured for PHP - Web server (Apache / Nginx / Caddy + PHPFPM)
- PHP extensions: `json`, `curl`, `zip` (and typical defaults). No database required. - PHP extensions: `json`, `curl`, `zip` (and usual defaults)
- No database required
**Download Files** **Steps**
```bash 1. Clone or download FileRise into your web root:
git clone https://github.com/error311/FileRise.git
```
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 ```bash
composer install 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 ```bash
mkdir -p uploads users metadata composer install
chown -R www-data:www-data uploads users metadata # use your web user ```
chmod -R 775 uploads users metadata
```
- `uploads/`: actual files 4. Configure PHP (upload limits / timeouts) and ensure rewrites are enabled.
- `users/`: credentials & token storage - Apache: allow `.htaccess` or copy its rules into your vhost.
- `metadata/`: file metadata (tags, share links, etc.) - 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`: For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki.
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
- `TOTAL_UPLOAD_SIZE` (ensure PHP `upload_max_filesize` and `post_max_size` meet/exceed this).
- `PERSISTENT_TOKENS_KEY` for “Remember Me” tokens.
**Share link base URL**
- Set **`SHARE_URL`** via web-server env vars (preferred),
**or** keep using `BASE_URL` in `config.php` as a fallback.
- If neither is set, FileRise auto-detects from the current host/scheme.
**Web server config**
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
- Nginx/other: replicate basic protections (no directory listing, deny sensitive files). See Wiki for examples.
Browse to your FileRise URL; youll be prompted to create the Admin user on first load.
--- ---
### 3) Admins ## 4. WebDAV & ONLYOFFICE (optional)
> **Admins in ACL UI** ### WebDAV
> 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.
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**. - FileRise is actively maintained and has published security advisories.
- Default **bridge**: access at `http://SERVER_IP:8080/`. - See **SECURITY.md** and GitHub Security Advisories for details.
- **Custom br0** (own IP): map host ports to **80/443** if you want bare `http://CONTAINER_IP/` without a port. - To upgrade:
- See the [support thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific help. - **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 - 🧵 **GitHub Discussions & Issues:** ask questions, report bugs, suggest features.
docker pull error311/filerise-docker:latest - 💬 **Unraid forum thread:** for Unraidspecific setup and tuning.
docker stop filerise && docker rm filerise - 🌍 **Reddit / selfhosting communities:** occasional release posts & feedback threads.
# re-run with the same -v and -e flags you used originally
``` 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 It bundles a small set of wellknown client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
# Linux (GVFS/GIO) All thirdparty code remains under its original licenses.
gio mount dav://demo@your-host/webdav.php/
# macOS (Finder → Go → Connect to Server…) See `THIRD_PARTY.md` and the `licenses/` folder for full details.
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.

View File

@@ -37,6 +37,10 @@ If you believe any attribution is missing or incorrect, please open an issue.
- **Resumable.js 1.1.0** — MIT License - **Resumable.js 1.1.0** — MIT License
**Files:** `public/vendor/resumable/1.1.0/resumable.min.js` **Files:** `public/vendor/resumable/1.1.0/resumable.min.js`
- **ReDoc (redoc.standalone.js)** — MIT License
**Files:** `public/vendor/redoc/redoc.standalone.js`
**Notes:** Self-hosted to comply with `script-src 'self'` CSP.
> MIT-licensed code: see `licenses/mit.txt`. > MIT-licensed code: see `licenses/mit.txt`.
> Apache-2.0licensed code: see `licenses/apache-2.0.txt`. > Apache-2.0licensed code: see `licenses/apache-2.0.txt`.

12
codeql-config.yml Normal file
View File

@@ -0,0 +1,12 @@
---
name: FileRise CodeQL config
paths:
- public/js
- api
paths-ignore:
- public/vendor/**
- public/css/vendor/**
- public/fonts/**
- public/**/*.min.js
- public/**/*.min.css
- public/**/*.map

View File

@@ -1,22 +1,6 @@
<?php <?php
// config.php // config.php
// Prevent caching
header("Cache-Control: no-cache, must-revalidate");
header("Pragma: no-cache");
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
header("Expires: 0");
// Security headers
header('X-Content-Type-Options: nosniff');
header("X-Frame-Options: SAMEORIGIN");
header("Referrer-Policy: no-referrer-when-downgrade");
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
header("X-XSS-Protection: 1; mode=block");
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
header("Strict-Transport-Security: max-age=31536000; includeSubDomains; preload");
}
// Define constants // Define constants
define('PROJECT_ROOT', dirname(__DIR__)); define('PROJECT_ROOT', dirname(__DIR__));
define('UPLOAD_DIR', '/var/www/uploads/'); define('UPLOAD_DIR', '/var/www/uploads/');
@@ -41,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); if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json'); define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
define('ACL_INHERIT_ON_CREATE', true); 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 // Encryption helpers
function encryptData($data, $encryptionKey) function encryptData($data, $encryptionKey)
@@ -243,4 +238,59 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
} }
// Final: env var wins, else fallback // 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,76 +1,128 @@
# -------------------------------- # --------------------------------
# Base: safe in most environments # FileRise portable .htaccess
# -------------------------------- # --------------------------------
Options -Indexes Options -Indexes -Multiviews
DirectoryIndex index.html DirectoryIndex index.html
# Allow PATH_INFO for routes like /webdav.php/foo/bar
AcceptPathInfo On
# ---------------- Security: dotfiles ----------------
<IfModule mod_authz_core.c> <IfModule mod_authz_core.c>
<FilesMatch "^\."> # Block direct access to dotfiles like .env, .gitignore, etc.
<FilesMatch "^\..*">
Require all denied Require all denied
</FilesMatch> </FilesMatch>
</IfModule> </IfModule>
# ---------------- Rewrites ----------------
<IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
#RewriteCond %{HTTPS} off
# 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]
# 4) HTTPS redirect (enable ONE of these, comment the other)
# A) Direct TLS on this server
#RewriteCond %{HTTPS} !=on
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] #RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# MIME types for fonts/SVG # 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]
# 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 ----------------
<IfModule mod_mime.c> <IfModule mod_mime.c>
AddType font/woff2 .woff2 AddType font/woff2 .woff2
AddType font/woff .woff AddType font/woff .woff
AddType image/svg+xml .svg AddType image/svg+xml .svg
AddType application/javascript .mjs
</IfModule> </IfModule>
# Security headers # ---------------- Security headers ----------------
<IfModule mod_headers.c> <IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN" Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block" Header always set X-XSS-Protection "1; mode=block"
Header always set X-Content-Type-Options "nosniff" Header always set X-Content-Type-Options "nosniff"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Header always set Referrer-Policy "strict-origin-when-cross-origin" Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()" Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
Header always set X-Download-Options "noopen" Header always set X-Download-Options "noopen"
Header always set Expect-CT "max-age=86400, enforce" Header always set Expect-CT "max-age=86400, enforce"
Header set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'" Header always set Cross-Origin-Resource-Policy "same-origin"
Header always set X-Permitted-Cross-Domain-Policies "none"
# 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> </IfModule>
# Caching # ---------------- Caching ----------------
SetEnvIfNoCase QUERY_STRING "(^|&)v=" has_version_param=1
<IfModule mod_headers.c> <IfModule mod_headers.c>
# HTML/PHP: no cache
<FilesMatch "\.(html?|php)$"> <FilesMatch "\.(html?|php)$">
Header set Cache-Control "no-cache, no-store, must-revalidate" Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache" Header setifempty Pragma "no-cache"
Header set Expires "0" Header setifempty Expires "0"
</FilesMatch> </FilesMatch>
# version.js: never cache
<FilesMatch "^js/version\.js$"> <FilesMatch "^js/version\.js$">
Header set Cache-Control "no-cache, no-store, must-revalidate" Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache" Header set Pragma "no-cache"
Header set Expires "0" Header set Expires "0"
</FilesMatch> </FilesMatch>
<FilesMatch "\.(js|css)$"> # JS/CSS: long cache if ?v= present, else 1h
Header set Cache-Control "public, max-age=3600, must-revalidate" env=!has_version_param <FilesMatch "\.(?:m?js|css)$">
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> </FilesMatch>
<FilesMatch "\.(png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$"> # Images/fonts: long cache if ?v= present, else 7d
Header set Cache-Control "public, max-age=604800" env=!has_version_param <FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
</FilesMatch> Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
Header set Cache-Control "public, max-age=604800" env=!IS_VER
<FilesMatch "\.(js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
Header set Cache-Control "public, max-age=31536000, immutable" env=has_version_param
</FilesMatch> </FilesMatch>
</IfModule> </IfModule>
# Compression (only if module exists) # ---------------- Compression ----------------
<IfModule mod_brotli.c> <IfModule mod_brotli.c>
BrotliCompressionQuality 5
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
</IfModule> </IfModule>
<IfModule mod_deflate.c> <IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
</IfModule> </IfModule>
# Disable TRACE # ---------------- Disable TRACE ----------------
<IfModule mod_rewrite.c>
RewriteCond %{REQUEST_METHOD} ^TRACE RewriteCond %{REQUEST_METHOD} ^TRACE
RewriteRule .* - [F] RewriteRule .* - [F]
</IfModule>

View File

@@ -19,13 +19,11 @@ if (isset($_GET['spec'])) {
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>FileRise API Docs</title> <title>FileRise API Docs</title>
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js" <script defer src="/vendor/redoc/redoc.standalone.js?v={{APP_QVER}}"></script>
integrity="sha384-70P5pmIdaQdVbxvjhrcTDv1uKcKqalZ3OHi7S2J+uzDl0PW8dO6L+pHOpm9EEjGJ" <script defer src="/js/redoc-init.js?v={{APP_QVER}}"></script>
crossorigin="anonymous"></script>
<script defer src="/js/redoc-init.js"></script>
</head> </head>
<body> <body>
<redoc spec-url="api.php?spec=1"></redoc> <redoc spec-url="/api.php?spec=1"></redoc>
<div id="redoc-container"></div> <div id="redoc-container"></div>
</body> </body>
</html> </html>

View File

@@ -3,83 +3,26 @@
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../../../../config/config.php'; require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php'; require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start(); if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json'); header('Content-Type: application/json');
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) { if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
} }
$user = trim((string)($_GET['user'] ?? '')); $user = trim((string)($_GET['user'] ?? ''));
if ($user === '' || !preg_match(REGEX_USER, $user)) {
http_response_code(400); echo json_encode(['error'=>'Invalid user']); exit;
}
// Build the folder list (admin sees all)
$folders = [];
try { try {
$rows = FolderModel::getFolderList(); $ctrl = new AclAdminController();
if (is_array($rows)) { $grants = $ctrl->getUserGrants($user);
foreach ($rows as $r) { echo json_encode(['grants' => $grants], JSON_UNESCAPED_SLASHES);
$f = is_array($r) ? ($r['folder'] ?? '') : (string)$r; } catch (InvalidArgumentException $e) {
if ($f !== '') $folders[$f] = true; http_response_code(400);
} echo json_encode(['error' => $e->getMessage()]);
} } catch (Throwable $e) {
} catch (Throwable $e) { /* ignore */ } http_response_code(500);
echo json_encode(['error' => 'Failed to load grants', 'detail' => $e->getMessage()]);
if (empty($folders)) { }
$aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json';
if (is_file($aclPath)) {
$data = json_decode((string)@file_get_contents($aclPath), true);
if (is_array($data['folders'] ?? null)) {
foreach ($data['folders'] as $name => $_) $folders[$name] = true;
}
}
}
$folderList = array_keys($folders);
if (!in_array('root', $folderList, true)) array_unshift($folderList, 'root');
$has = function(array $arr, string $u): bool {
foreach ($arr as $x) if (strcasecmp((string)$x, $u) === 0) return true;
return false;
};
$out = [];
foreach ($folderList as $f) {
$rec = ACL::explicitAll($f); // legacy + granular
$isOwner = $has($rec['owners'], $user);
$canViewAll = $isOwner || $has($rec['read'], $user);
$canViewOwn = $has($rec['read_own'], $user);
$canShare = $isOwner || $has($rec['share'], $user);
$canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user);
if ($canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner
|| $has($rec['create'],$user) || $has($rec['edit'],$user) || $has($rec['rename'],$user)
|| $has($rec['copy'],$user) || $has($rec['move'],$user) || $has($rec['delete'],$user)
|| $has($rec['extract'],$user) || $has($rec['share_file'],$user) || $has($rec['share_folder'],$user)) {
$out[$f] = [
'view' => $canViewAll,
'viewOwn' => $canViewOwn,
'write' => $has($rec['write'], $user) || $isOwner,
'manage' => $isOwner,
'share' => $canShare, // legacy
'create' => $isOwner || $has($rec['create'], $user),
'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'],$user),
'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'],$user),
'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'],$user),
'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'],$user),
'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'],$user),
'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'],$user),
'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'],$user),
'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'],$user),
'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'],$user),
];
}
}
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);

View File

@@ -3,12 +3,11 @@
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../../../../config/config.php'; require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php'; require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start(); if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json'); header('Content-Type: application/json');
// ---- Auth + CSRF -----------------------------------------------------------
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) { if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
http_response_code(401); http_response_code(401);
echo json_encode(['error' => 'Unauthorized']); echo json_encode(['error' => 'Unauthorized']);
@@ -24,98 +23,17 @@ if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
exit; exit;
} }
// ---- Helpers ---------------------------------------------------------------
function normalize_caps(array $row): array {
// booleanize known keys
$bool = function($v){ return !empty($v) && $v !== 'false' && $v !== 0; };
$k = [
'view','viewOwn','upload','manage','share',
'create','edit','rename','copy','move','delete','extract',
'shareFile','shareFolder','write'
];
$out = [];
foreach ($k as $kk) $out[$kk] = $bool($row[$kk] ?? false);
// BUSINESS RULES:
// A) Share Folder REQUIRES View (all). If shareFolder is true but view is false, force view=true.
if ($out['shareFolder'] && !$out['view']) {
$out['view'] = true;
}
// B) Share File requires at least View (own). If neither view nor viewOwn set, set viewOwn=true.
if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) {
$out['viewOwn'] = true;
}
// C) "write" does NOT imply view. It also does not imply granular here; ACL expands legacy write if present.
return $out;
}
function sanitize_grants_map(array $grants): array {
$out = [];
foreach ($grants as $folder => $caps) {
if (!is_string($folder)) $folder = (string)$folder;
if (!is_array($caps)) $caps = [];
$out[$folder] = normalize_caps($caps);
}
return $out;
}
function valid_user(string $u): bool {
return ($u !== '' && preg_match(REGEX_USER, $u));
}
// ---- Read JSON body --------------------------------------------------------
$raw = file_get_contents('php://input'); $raw = file_get_contents('php://input');
$in = json_decode((string)$raw, true); $in = json_decode((string)$raw, true);
if (!is_array($in)) {
try {
$ctrl = new AclAdminController();
$res = $ctrl->saveUserGrantsPayload($in ?? []);
echo json_encode($res, JSON_UNESCAPED_SLASHES);
} catch (InvalidArgumentException $e) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'Invalid JSON']); echo json_encode(['error' => $e->getMessage()]);
exit; } catch (Throwable $e) {
} http_response_code(500);
echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]);
// ---- Single user mode: { user, grants } ------------------------------------ }
if (isset($in['user']) && isset($in['grants']) && is_array($in['grants'])) {
$user = trim((string)$in['user']);
if (!valid_user($user)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid user']);
exit;
}
$grants = sanitize_grants_map($in['grants']);
try {
$res = ACL::applyUserGrantsAtomic($user, $grants);
echo json_encode($res, JSON_UNESCAPED_SLASHES);
exit;
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]);
exit;
}
}
// ---- Batch mode: { changes: [ { user, grants }, ... ] } --------------------
if (isset($in['changes']) && is_array($in['changes'])) {
$updated = [];
foreach ($in['changes'] as $chg) {
if (!is_array($chg)) continue;
$user = trim((string)($chg['user'] ?? ''));
$gr = $chg['grants'] ?? null;
if (!valid_user($user) || !is_array($gr)) continue;
try {
$res = ACL::applyUserGrantsAtomic($user, sanitize_grants_map($gr));
$updated[$user] = $res['updated'] ?? [];
} catch (Throwable $e) {
$updated[$user] = ['error' => $e->getMessage()];
}
}
echo json_encode(['ok' => true, 'updated' => $updated], JSON_UNESCAPED_SLASHES);
exit;
}
// ---- Fallback --------------------------------------------------------------
http_response_code(400);
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);

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 <?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); 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 __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php'; require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.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 --- $folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$username = $_SESSION['username'] ?? ''; $folder = str_replace('\\', '/', trim($folder));
if ($username === '') { $folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// --- helpers --- echo json_encode(FolderController::capabilities($folder, $username), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
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);

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

View File

@@ -0,0 +1,32 @@
<?php
// public/api/pro/groups/list.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
try {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
$ctrl = new AdminController();
$groups = $ctrl->getProGroups();
echo json_encode([
'success' => true,
'groups' => $groups,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
$code = $e instanceof InvalidArgumentException ? 400 : 500;
http_response_code($code);
echo json_encode([
'success' => false,
'error' => 'Error loading groups: ' . $e->getMessage(),
]);
}

View File

@@ -0,0 +1,51 @@
<?php
// public/api/pro/groups/save.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
return;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
AdminController::requireCsrf();
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON payload.']);
return;
}
$groups = $body['groups'] ?? null;
if (!is_array($groups)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid groups format.']);
return;
}
$ctrl = new AdminController();
$ctrl->saveProGroups($groups);
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
$code = $e instanceof InvalidArgumentException ? 400 : 500;
http_response_code($code);
echo json_encode([
'success' => false,
'error' => 'Error saving groups: ' . $e->getMessage(),
]);
}

View File

@@ -0,0 +1,28 @@
<?php
// public/api/pro/uploadBrandLogo.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
header('Content-Type: application/json; charset=utf-8');
// Pro-only gate
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode([
'success' => false,
'error' => 'FileRise Pro is not active on this instance.'
]);
exit;
}
try {
$ctrl = new UserController();
$ctrl->uploadBrandLogo();
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Exception: ' . $e->getMessage(),
]);
}

View File

@@ -0,0 +1,9 @@
<?php
// public/api/siteConfig.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController();
$userController->siteConfig();

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

View File

@@ -4,7 +4,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2) format('woff2'); src: url('/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2?v={{APP_QVER}}') format('woff2');
} }
.material-icons { .material-icons {

View File

@@ -4,7 +4,7 @@
font-style:normal; font-style:normal;
font-weight:400; font-weight:400;
font-display:swap; font-display:swap;
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2') format('woff2'); src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}') format('woff2');
unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
} }
/* Roboto Regular 400 — latin */ /* Roboto Regular 400 — latin */
@@ -13,7 +13,7 @@
font-style:normal; font-style:normal;
font-weight:400; font-weight:400;
font-display:swap; font-display:swap;
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2') format('woff2'); src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}') format('woff2');
unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }
/* Roboto Medium 500 — latin-ext */ /* Roboto Medium 500 — latin-ext */
@@ -22,7 +22,7 @@
font-style:normal; font-style:normal;
font-weight:500; font-weight:500;
font-display:swap; font-display:swap;
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2') format('woff2'); src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}') format('woff2');
unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
} }
/* Roboto Medium 500 — latin */ /* Roboto Medium 500 — latin */
@@ -31,7 +31,7 @@
font-style:normal; font-style:normal;
font-weight:500; font-weight:500;
font-display:swap; font-display:swap;
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2') format('woff2'); src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}') format('woff2');
unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -2,116 +2,67 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="theme-color" content="#0b5ed7">
<title>FileRise</title> <script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
<link rel="icon" type="image/png" href="/assets/logo.png"> <style id="pretheme-css">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg"> html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
<meta name="csrf-token" content=""> </style>
<meta name="share-url" content=""> <!-- Favicons (ordered: SVG -> PNGs -> ICO) -->
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
<style>.main-wrapper{display:none}#loadingOverlay{position:fixed;inset:0;background:var(--bg-color,#fff);z-index:9999;display:flex;align-items:center;justify-content:center}</style> <link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
<link rel="stylesheet" href="/css/vendor/roboto.css?v=dev"> <link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
<link rel="stylesheet" href="/css/vendor/material-icons.css?v=dev"> <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}}">
<!-- Bootstrap CSS (local) -->
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v=dev"> <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">
<!-- CodeMirror CSS (local) --> <link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
<link rel="stylesheet" href="/vendor/codemirror/5.65.5/codemirror.min.css?v=dev"> <link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
<link rel="stylesheet" href="/vendor/codemirror/5.65.5/theme/material-darker.min.css?v=dev">
<!-- Critical CSS -->
<!-- app CSS --> <link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/styles.css?v=dev"> <link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
<!-- Libraries (JS) -->
<script src="/vendor/dompurify/2.4.0/purify.min.js?v=dev"></script> <!-- Fonts (ok to keep as real preloads) -->
<script src="/vendor/fuse/6.6.2/fuse.min.js?v=dev"></script> <link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
<script src="/vendor/resumable/1.1.0/resumable.min.js?v=dev"></script> <link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
<!-- CodeMirror core FIRST, then modes --> <!-- Vendor & version (deferred) -->
<script src="/vendor/codemirror/5.65.5/codemirror.min.js?v=dev"></script> <script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}" defer></script>
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
<script src="/js/version.js?v=dev"></script>
<script type="module" src="/js/main.js"></script> <!-- App entry -->
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
</head> </head>
<body> <body>
<div id="appRoot" style="visibility:hidden">
<header class="header-container"> <header class="header-container">
<div class="header-left"> <div class="header-left">
<a href="index.html"> <a href="index.html">
<div class="header-logo"> <div class="header-logo">
<svg version="1.1" id="filingCabinetLogo" xmlns="http://www.w3.org/2000/svg" <img
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64" xml:space="preserve"> src="/assets/logo.svg?v={{APP_QVER}}"
<defs> alt="FileRise"
<!-- Gradient for the cabinet body --> class="logo"
<linearGradient id="cabinetGradient" x1="0%" y1="0%" x2="0%" y2="100%"> width="50"
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" /> height="50"
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" /> decoding="async"
</linearGradient> fetchpriority="high"
<!-- Drop shadow filter with animated attributes for a lifting effect --> />
<filter id="shadowFilter" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow id="dropShadow" dx="0" dy="2" stdDeviation="2" flood-color="#000" flood-opacity="0.2">
<!-- Animate the vertical offset: from 2 to 1 (as it rises), hold, then back to 2 -->
<animate attributeName="dy" values="2;1;1;2" keyTimes="0;0.2;0.8;1" dur="5s" fill="freeze" />
<!-- Animate the blur similarly: from 2 to 1.5 then back to 2 -->
<animate attributeName="stdDeviation" values="2;1.5;1.5;2" keyTimes="0;0.2;0.8;1" dur="5s"
fill="freeze" />
</feDropShadow>
</filter>
</defs>
<style type="text/css">
/* Cabinet with gradient, white outline, and drop shadow */
.cabinet {
fill: url(#cabinetGradient);
stroke: white;
stroke-width: 2;
}
.divider {
stroke: #1565C0;
stroke-width: 1.5;
}
.drawer {
fill: #FFFFFF;
}
.handle {
fill: #1565C0;
}
</style>
<!-- Group that will animate upward and then back down once -->
<g id="cabinetGroup">
<!-- Cabinet Body with rounded corners, white outline, and drop shadow -->
<rect x="4" y="4" width="56" height="56" rx="6" ry="6" class="cabinet" filter="url(#shadowFilter)" />
<!-- Divider lines for drawers -->
<line x1="5" y1="22" x2="59" y2="22" class="divider" />
<line x1="5" y1="34" x2="59" y2="34" class="divider" />
<!-- Drawers with Handles -->
<rect x="8" y="24" width="48" height="6" rx="1" ry="1" class="drawer" />
<circle cx="54" cy="27" r="1.5" class="handle" />
<rect x="8" y="36" width="48" height="6" rx="1" ry="1" class="drawer" />
<circle cx="54" cy="39" r="1.5" class="handle" />
<rect x="8" y="48" width="48" height="6" rx="1" ry="1" class="drawer" />
<circle cx="54" cy="51" r="1.5" class="handle" />
<!-- Additional detail: a small top handle on the cabinet door -->
<rect x="28" y="10" width="8" height="4" rx="1" ry="1" fill="#1565C0" />
<!-- Animate transform: rises by 2 pixels over 1s, holds for 3s, then falls over 1s (total 5s) -->
<animateTransform attributeName="transform" type="translate" values="0 0; 0 -2; 0 -2; 0 0"
keyTimes="0;0.2;0.8;1" dur="5s" fill="freeze" />
</g>
</svg>
</div> </div>
</a> </a>
</div> </div>
<div class="header-title"> <div class="header-title">
<h1 data-i18n-key="header_title">FileRise</h1> <h1>FileRise</h1>
</div> </div>
<div class="header-right"> <div class="header-right">
<div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;"> <div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;">
<!-- Your header drop zone -->
<div id="headerDropArea" class="header-drop-zone"></div> <div id="headerDropArea" class="header-drop-zone"></div>
<div class="header-buttons"> <div class="header-buttons">
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;"> <button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
@@ -146,7 +97,7 @@
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;"> <button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
<i class="material-icons">person_remove</i> <i class="material-icons">person_remove</i>
</button> </button>
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode"> <button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode" hidden>
<span class="material-icons" id="darkModeIcon"> <span class="material-icons" id="darkModeIcon">
dark_mode dark_mode
</span> </span>
@@ -155,15 +106,18 @@
</div> </div>
</div> </div>
</header> </header>
<div id="loadingOverlay"></div> <div id="loadingOverlay"></div>
<!-- Custom Toast Container --> <!-- Custom Toast Container -->
<div id="customToast"></div> <div id="customToast"></div>
<div id="hiddenCardsContainer" style="display:none;"></div> <div id="hiddenCardsContainer" style="display:none;"></div>
<main id="main" hidden>
<div class="row mt-4" id="loginForm"> <div class="row mt-4" id="loginForm">
<div class="col-12"> <div class="col-12">
<div id="loginBox" class="login-box">
<div id="fr-login-tip" class="alert alert-info login-hint" role="status" aria-live="polite" style="display:none;"></div>
<form id="authForm" method="post"> <form id="authForm" method="post">
<div class="form-group"> <div class="form-group">
<label for="loginUsername" data-i18n-key="user">User:</label> <label for="loginUsername" data-i18n-key="user">User:</label>
@@ -173,7 +127,7 @@
<label for="loginPassword" data-i18n-key="password">Password:</label> <label for="loginPassword" data-i18n-key="password">Password:</label>
<input type="password" class="form-control" id="loginPassword" name="password" required /> <input type="password" class="form-control" id="loginPassword" name="password" required />
</div> </div>
<button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login">Login</button> <button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login" data-default>Login</button>
<div class="form-group remember-me-container"> <div class="form-group remember-me-container">
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" /> <input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
<label for="rememberMeCheckbox" data-i18n-key="remember_me">Remember me</label> <label for="rememberMeCheckbox" data-i18n-key="remember_me">Remember me</label>
@@ -189,11 +143,14 @@
HTTP HTTP
Login</a> Login</a>
</div> </div>
<div>
</div> </div>
</div> </div>
</main>
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login --> <!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
<div class="main-wrapper"> <div class="main-wrapper" hidden>
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) --> <!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
<div id="sidebarDropArea" class="drop-target-sidebar"></div> <div id="sidebarDropArea" class="drop-target-sidebar"></div>
<!-- Main Column --> <!-- Main Column -->
@@ -273,7 +230,7 @@
<div class="modal-footer" style="margin-top:15px; text-align:right;"> <div class="modal-footer" style="margin-top:15px; text-align:right;">
<button id="cancelMoveFolder" class="btn btn-secondary" <button id="cancelMoveFolder" class="btn btn-secondary"
data-i18n-key="cancel">Cancel</button> data-i18n-key="cancel">Cancel</button>
<button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move">Move</button> <button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move" data-default>Move</button>
</div> </div>
</div> </div>
</div> </div>
@@ -291,10 +248,13 @@
<button id="cancelRenameFolder" class="btn btn-secondary" <button id="cancelRenameFolder" class="btn btn-secondary"
data-i18n-key="cancel">Cancel</button> data-i18n-key="cancel">Cancel</button>
<button id="submitRenameFolder" class="btn btn-primary" <button id="submitRenameFolder" class="btn btn-primary"
data-i18n-key="rename">Rename</button> data-i18n-key="rename" data-default>Rename</button>
</div> </div>
</div> </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"> <button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
<i class="material-icons">share</i> <i class="material-icons">share</i>
@@ -311,20 +271,30 @@
<button id="cancelDeleteFolder" class="btn btn-secondary" <button id="cancelDeleteFolder" class="btn btn-secondary"
data-i18n-key="cancel">Cancel</button> data-i18n-key="cancel">Cancel</button>
<button id="confirmDeleteFolder" class="btn btn-danger" <button id="confirmDeleteFolder" class="btn btn-danger"
data-i18n-key="delete">Delete</button> data-i18n-key="delete" data-default>Delete</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div id="folderHelpTooltip" class="folder-help-tooltip" <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);"> 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;">
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;"> <style>
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li> /* Dark mode polish */
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li> body.dark-mode #folderHelpTooltip {
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a background:#2c2c2c; border-color:#555; color:#e8e8e8; box-shadow:2px 2px 10px rgba(0,0,0,.5);
subfolder.</li> }
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click #folderHelpTooltip .folder-help-list { margin:0; padding-left:18px; }
the appropriate button.</li> #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> </ul>
</div> </div>
</div> </div>
@@ -347,7 +317,7 @@
selected files?</p> selected files?</p>
<div class="modal-footer"> <div class="modal-footer">
<button id="cancelDeleteFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelDeleteFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmDeleteFiles" class="btn btn-danger" data-i18n-key="delete">Delete</button> <button id="confirmDeleteFiles" class="btn btn-danger" data-i18n-key="delete" data-default>Delete</button>
</div> </div>
</div> </div>
</div> </div>
@@ -361,7 +331,7 @@
<select id="copyTargetFolder" class="form-control modal-input"></select> <select id="copyTargetFolder" class="form-control modal-input"></select>
<div class="modal-footer"> <div class="modal-footer">
<button id="cancelCopyFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelCopyFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmCopyFiles" class="btn btn-primary" data-i18n-key="copy">Copy</button> <button id="confirmCopyFiles" class="btn btn-primary" data-i18n-key="copy" data-default>Copy</button>
</div> </div>
</div> </div>
</div> </div>
@@ -375,7 +345,7 @@
<select id="moveTargetFolder" class="form-control modal-input"></select> <select id="moveTargetFolder" class="form-control modal-input"></select>
<div class="modal-footer"> <div class="modal-footer">
<button id="cancelMoveFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelMoveFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmMoveFiles" class="btn btn-primary" data-i18n-key="move">Move</button> <button id="confirmMoveFiles" class="btn btn-primary" data-i18n-key="move" data-default>Move</button>
</div> </div>
</div> </div>
</div> </div>
@@ -383,35 +353,24 @@
data-i18n-key="download_zip">Download ZIP</button> data-i18n-key="download_zip">Download ZIP</button>
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" style="display: none;" disabled <button id="extractZipBtn" class="btn action-btn btn-sm btn-info" style="display: none;" disabled
data-i18n-key="extract_zip_button">Extract Zip</button> data-i18n-key="extract_zip_button">Extract Zip</button>
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;"> <div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
<button id="createBtn" class="btn action-btn" style="display: none;" data-i18n-key="create"> <button id="createBtn" class="btn action-btn" type="button" style="display:none;" aria-haspopup="true" aria-expanded="false">
${t('create')} <span class="material-icons" <span data-i18n-key="create">Create</span>
style="font-size:16px;vertical-align:middle;">arrow_drop_down</span> <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
</button> </button>
<ul id="createMenu" class="dropdown-menu" style=" <ul id="createMenu" class="dropdown-menu" style="display:none; position:absolute; top:100%; left:0; margin:4px 0 0; padding:0; list-style:none; background:#fff; border:1px solid #ccc; box-shadow:0 2px 6px rgba(0,0,0,0.2); z-index:10010; min-width:160px;">
display: none; <li id="createFileOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
position: absolute; <span data-i18n-key="create_file">Create file</span>
top: 100%; </li>
left: 0; <li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
margin: 4px 0 0; <span data-i18n-key="create_folder">Create folder</span>
padding: 0; </li>
list-style: none; <li id="uploadOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
background: #fff; <span data-i18n-key="upload">Upload file(s)</span>
border: 1px solid #ccc; </li>
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
z-index: 1000; </ul>
min-width: 140px; </div>
">
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file"
style="padding:8px 12px; cursor:pointer;">
${t('create_file')}
</li>
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder"
style="padding:8px 12px; cursor:pointer;">
${t('create_folder')}
</li>
</ul>
</div>
<!-- Create File Modal --> <!-- Create File Modal -->
<div id="createFileModal" class="modal" style="display:none;"> <div id="createFileModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
@@ -420,7 +379,7 @@
data-i18n-placeholder="newfile_placeholder" /> data-i18n-placeholder="newfile_placeholder" />
<div class="modal-footer" style="margin-top:1rem; text-align:right;"> <div class="modal-footer" style="margin-top:1rem; text-align:right;">
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button> <button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create" data-default>Create</button>
</div> </div>
</div> </div>
</div> </div>
@@ -432,7 +391,7 @@
placeholder="files.zip" /> placeholder="files.zip" />
<div class="modal-footer" style="margin-top:15px; text-align:right;"> <div class="modal-footer" style="margin-top:15px; text-align:right;">
<button id="cancelDownloadZip" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelDownloadZip" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmDownloadZip" class="btn btn-primary" data-i18n-key="download">Download</button> <button id="confirmDownloadZip" class="btn btn-primary" data-i18n-key="download" data-default>Download</button>
</div> </div>
</div> </div>
</div> </div>
@@ -470,14 +429,14 @@
placeholder="Filename" /> placeholder="Filename" />
<div style="margin-top: 15px; text-align: right;"> <div style="margin-top: 15px; text-align: right;">
<button id="cancelDownloadFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelDownloadFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download">Download</button> <button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download" data-default>Download</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) --> <!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
<div id="changePasswordModal" class="modal" style="display:none;"> <div id="changePasswordModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:400px; margin:auto;"> <div class="modal-content" style="text-align: center; padding: 20px;">
<span id="closeChangePasswordModal" class="editor-close-btn">&times;</span> <span id="closeChangePasswordModal" class="editor-close-btn">&times;</span>
<h3 data-i18n-key="change_password_title">Change Password</h3> <h3 data-i18n-key="change_password_title">Change Password</h3>
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password" <input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
@@ -486,7 +445,7 @@
placeholder="New Password" style="width:100%; margin: 5px 0;" /> placeholder="New Password" style="width:100%; margin: 5px 0;" />
<input type="password" id="confirmPassword" class="form-control" data-i18n-placeholder="confirm_new_password" <input type="password" id="confirmPassword" class="form-control" data-i18n-placeholder="confirm_new_password"
placeholder="Confirm New Password" style="width:100%; margin: 5px 0;" /> placeholder="Confirm New Password" style="width:100%; margin: 5px 0;" />
<button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;">Save</button> <button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;" data-default>Save</button>
</div> </div>
</div> </div>
<div id="addUserModal" class="modal" style="display:none;"> <div id="addUserModal" class="modal" style="display:none;">
@@ -511,13 +470,33 @@
Cancel Cancel
</button> </button>
<!-- Save becomes type="submit" --> <!-- Save becomes type="submit" -->
<button type="submit" id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user"> <button type="submit" id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user" data-default>
Save User Save User
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</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 id="removeUserModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h3 data-i18n-key="remove_user_title">Remove User</h3> <h3 data-i18n-key="remove_user_title">Remove User</h3>
@@ -536,7 +515,7 @@
placeholder="Enter new file name" style="margin-top:10px;" /> placeholder="Enter new file name" style="margin-top:10px;" />
<div style="margin-top:15px; text-align:right;"> <div style="margin-top:15px; text-align:right;">
<button id="cancelRenameFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelRenameFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="submitRenameFile" class="btn btn-primary" data-i18n-key="rename">Rename</button> <button id="submitRenameFile" class="btn btn-primary" data-i18n-key="rename" data-default>Rename</button>
</div> </div>
</div> </div>
</div> </div>
@@ -544,12 +523,24 @@
<div class="modal-content"> <div class="modal-content">
<p id="confirmMessage"></p> <p id="confirmMessage"></p>
<div class="modal-actions"> <div class="modal-actions">
<button id="confirmYesBtn" class="btn btn-primary" data-i18n-key="yes">Yes</button> <button id="confirmYesBtn" class="btn btn-primary" data-i18n-key="yes" data-default>Yes</button>
<button id="confirmNoBtn" class="btn btn-secondary" data-i18n-key="no">No</button> <button id="confirmNoBtn" class="btn btn-secondary" data-i18n-key="no">No</button>
</div> </div>
</div> </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> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

219
public/js/appCore.js Normal file
View File

@@ -0,0 +1,219 @@
// /js/appCore.js
import { showToast } from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
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, 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);
/* =========================
CSRF UTILITIES (shared)
========================= */
export function setCsrfToken(token) {
if (!token) return;
window.csrfToken = token;
localStorage.setItem('csrf', token);
// meta tag for easy access in other places
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'csrf-token';
document.head.appendChild(meta);
}
meta.content = token;
}
export function getCsrfToken() {
return window.csrfToken || localStorage.getItem('csrf') || '';
}
/**
* Bootstrap/refresh CSRF from the server.
* Uses the native fetch to avoid wrapper loops and accepts rotated tokens via header.
*/
export async function loadCsrfToken() {
const res = await _nativeFetch('/api/auth/token.php', { method: 'GET', credentials: 'include' });
// header-based rotation
const hdr = res.headers.get('X-CSRF-Token');
if (hdr) setCsrfToken(hdr);
// body (if provided)
let body = {};
try { body = await res.json(); } catch { /* token endpoint may return empty */ }
const token = body.csrf_token || getCsrfToken();
setCsrfToken(token);
// share-url meta should reflect the actual origin
const actualShare = window.location.origin;
let shareMeta = document.querySelector('meta[name="share-url"]');
if (!shareMeta) {
shareMeta = document.createElement('meta');
shareMeta.name = 'share-url';
document.head.appendChild(shareMeta);
}
shareMeta.content = actualShare;
return { csrf_token: token, share_url: actualShare };
}
/* =========================
APP INIT (shared)
========================= */
export function initializeApp() {
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
const last = localStorage.getItem('lastOpenedFolder');
window.currentFolder = last ? last : "root";
const stored = localStorage.getItem('showFoldersInList');
window.showFoldersInList = stored === null ? true : stored === 'true';
// Load public site config early (safe subset)
loadAdminConfigFunc();
// 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('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', async e => {
e.preventDefault();
fileListArea.classList.remove('drop-hover');
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 }));
}
});
}
// App subsystems
initDragAndDrop();
loadSidebarOrder();
loadHeaderOrder();
initFileActions();
initUpload();
loadFolderTree();
// Only run trash/restore for admins
const isAdmin =
localStorage.getItem('isAdmin') === '1' || localStorage.getItem('isAdmin') === 'true';
if (isAdmin) {
setupTrashRestoreDelete();
}
// Small help tooltip toggle
const helpBtn = document.getElementById("folderHelpBtn");
const helpTooltip = document.getElementById("folderHelpTooltip");
if (helpBtn && helpTooltip) {
helpBtn.addEventListener("click", () => {
helpTooltip.style.display =
helpTooltip.style.display === "block" ? "none" : "block";
});
}
}
/* =========================
LOGOUT (shared)
========================= */
export function triggerLogout() {
const clearWelcomeFlags = () => {
try {
// one-per-tab toast guard
sessionStorage.removeItem('__fr_welcomed');
// if you also used the per-user (all-tabs) guard, clear that too:
const u = localStorage.getItem('username') || '';
if (u) localStorage.removeItem(`__fr_welcomed_${u}`);
} catch { }
};
_nativeFetch("/api/auth/logout.php", {
method: "POST",
credentials: "include",
headers: { "X-CSRF-Token": getCsrfToken() }
})
.then(() => {
clearWelcomeFlags();
window.location.reload(true);
})
.catch(() => {
// even if the request fails, clear the flags so the next login can toast
clearWelcomeFlags();
window.location.reload(true);
});
}
/* =========================
Global UX guard (unchanged)
========================= */
window.addEventListener("unhandledrejection", (ev) => {
const msg = (ev?.reason && ev.reason.message) || "";
if (msg === "auth") {
showToast(t("please_sign_in_again") || "Please sign in again.", "error");
ev.preventDefault();
} else if (msg === "forbidden") {
showToast(t("no_access_to_resource") || "You dont have access to that.", "error");
ev.preventDefault();
}
});

View File

@@ -1,15 +1,15 @@
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
import { t, applyTranslations } from './i18n.js'; import { t, applyTranslations } from './i18n.js?v={{APP_QVER}}';
import { import {
toggleVisibility, toggleVisibility,
showToast as originalShowToast, showToast as originalShowToast,
attachEnterKeyListener, attachEnterKeyListener,
showCustomConfirmModal showCustomConfirmModal
} from './domUtils.js'; } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { initFileActions } from './fileActions.js'; import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
import { renderFileTable } from './fileListView.js'; import { renderFileTable } from './fileListView.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js'; import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { import {
openTOTPLoginModal as originalOpenTOTPLoginModal, openTOTPLoginModal as originalOpenTOTPLoginModal,
openUserPanel, openUserPanel,
@@ -17,9 +17,9 @@ import {
closeTOTPModal, closeTOTPModal,
setLastLoginData, setLastLoginData,
openApiModal openApiModal
} from './authModals.js'; } from './authModals.js?v={{APP_QVER}}';
import { openAdminPanel } from './adminPanel.js'; import { openAdminPanel } from './adminPanel.js?v={{APP_QVER}}';
import { initializeApp, triggerLogout } from './main.js'; import { initializeApp, triggerLogout } from './appCore.js?v={{APP_QVER}}';
// Production OIDC configuration (override via API as needed) // Production OIDC configuration (override via API as needed)
const currentOIDCConfig = { const currentOIDCConfig = {
@@ -31,6 +31,49 @@ const currentOIDCConfig = {
}; };
window.currentOIDCConfig = currentOIDCConfig; window.currentOIDCConfig = currentOIDCConfig;
(function installToastFilter() {
const isDemoHost = location.hostname.toLowerCase() === 'demo.filerise.net';
window.__FR_TOAST_FILTER__ = function (msgKeyOrText) {
// Suppress the nag while doing TOTP step-up
if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' ||
/please log in/i.test(String(msgKeyOrText)))) {
return null; // suppress
}
// Demo host
if (isDemoHost && (msgKeyOrText === 'please_log_in_to_continue' ||
/please log in/i.test(String(msgKeyOrText)))) {
return "Demo site — use:\nUsername: demo\nPassword: demo";
}
// Try to translate keys; pass through plain text
try {
const maybe = t(msgKeyOrText);
if (typeof maybe === 'string' && maybe !== msgKeyOrText) return maybe;
} catch { }
return msgKeyOrText;
};
})();
function queueWelcomeToast(name) {
const uname = String(name || '').trim().slice(0, 80);
if (!uname) return;
// show immediately (if we dont reload instantly)
try {
window.dispatchEvent(new CustomEvent('filerise:toast', {
detail: { message: `Welcome back, ${uname}!`, duration: 2000 }
}));
} catch { }
// and persist for after-reload (flushed by main.js on boot)
try {
sessionStorage.setItem('welcomeMessage', `Welcome back, ${uname}!`);
} catch { }
}
/* ----------------- TOTP & Toast Overrides ----------------- */ /* ----------------- TOTP & Toast Overrides ----------------- */
// detect if were in a pendingTOTP state // detect if were in a pendingTOTP state
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1'; window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
@@ -72,45 +115,51 @@ const originalFetch = window.fetch;
* @param {object} options * @param {object} options
* @returns {Promise<Response>} * @returns {Promise<Response>}
*/ */
export async function fetchWithCsrf(url, options = {}) { export async function fetchWithCsrf(url, options = {}) {
// 1) Merge in credentials + header const original = window.fetch.bind(window);
options = { const wantJson = (options.headers && /json/i.test(options.headers['Content-Type'] || '')) || typeof options.body === 'string' && options.body.trim().startsWith('{');
credentials: 'include',
...options, options = { credentials: 'include', ...options };
};
options.headers = { options.headers = {
...(options.headers || {}), 'Accept': 'application/json',
'X-CSRF-Token': window.csrfToken, ...(options.headers || {})
}; };
if (window.csrfToken) {
// 2) First attempt options.headers['X-CSRF-Token'] = window.csrfToken;
let res = await originalFetch(url, options);
// 3) If we got a 403, try to refresh token & retry
if (res.status === 403) {
// 3a) See if the server gave us a new token header
let newToken = res.headers.get('X-CSRF-Token');
// 3b) Otherwise fall back to the /api/auth/token endpoint
if (!newToken) {
const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' });
if (tokRes.ok) {
const body = await tokRes.json();
newToken = body.csrf_token;
}
}
if (newToken) {
// 3c) Update global + meta
window.csrfToken = newToken;
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) meta.content = newToken;
// 3d) Retry the original request with the new token
options.headers['X-CSRF-Token'] = newToken;
res = await originalFetch(url, options);
}
} }
// 4) Return the real Response—no body peeking here! async function retryWithFreshCsrf(asFormFallback = false) {
const tokRes = await original('/api/auth/token.php', { credentials: 'include' });
if (tokRes.ok) {
const body = await tokRes.json().catch(() => ({}));
if (body?.csrf_token) {
window.csrfToken = body.csrf_token;
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) meta.content = body.csrf_token;
options.headers['X-CSRF-Token'] = body.csrf_token;
}
}
if (asFormFallback && wantJson) {
// convert JSON body into x-www-form-urlencoded
const orig = options.body && typeof options.body === 'string' ? JSON.parse(options.body) : {};
options.body = toFormBody(orig);
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
return original(url, options);
}
let res = await original(url, options);
// If API doesnt like JSON or token is stale
if (res.status === 400 || res.status === 403 || res.status === 415) {
// 1) retry with fresh CSRF keeping same encoding
res = await retryWithFreshCsrf(false);
if (!res.ok && wantJson) {
// 2) retry again as form-encoded
res = await retryWithFreshCsrf(true);
}
}
return res; return res;
} }
@@ -180,7 +229,7 @@ function updateLoginOptionsUIFromStorage() {
} }
export function loadAdminConfigFunc() { export function loadAdminConfigFunc() {
return fetch("/api/admin/getConfig.php", { credentials: "include" }) return fetch("/api/siteConfig.php", { credentials: "include" })
.then(async (response) => { .then(async (response) => {
// If a proxy or some edge returns 204/empty, handle gracefully // If a proxy or some edge returns 204/empty, handle gracefully
let config = {}; let config = {};
@@ -191,13 +240,13 @@ export function loadAdminConfigFunc() {
document.title = headerTitle; document.title = headerTitle;
const lo = config.loginOptions || {}; const lo = config.loginOptions || {};
localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin)); localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin));
localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth)); localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth));
localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin)); localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin));
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise"); localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
// These may be absent for non-admins; default them // These may be absent for non-admins; default them
localStorage.setItem("authBypass", String(!!lo.authBypass)); localStorage.setItem("authBypass", String(!!lo.authBypass));
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User"); localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
updateLoginOptionsUIFromStorage(); updateLoginOptionsUIFromStorage();
@@ -253,14 +302,14 @@ export async function updateAuthenticatedUI(data) {
if (loading) loading.remove(); if (loading) loading.remove();
// 2) Show main UI // 2) Show main UI
document.querySelector('.main-wrapper').style.display = ''; document.querySelector('.main-wrapper').style.display = '';
document.getElementById('loginForm').style.display = 'none'; document.getElementById('loginForm').style.display = 'none';
toggleVisibility("loginForm", false); toggleVisibility("loginForm", false);
toggleVisibility("mainOperations", true); toggleVisibility("mainOperations", true);
toggleVisibility("uploadFileForm", true); toggleVisibility("uploadFileForm", true);
toggleVisibility("fileListContainer", true); toggleVisibility("fileListContainer", true);
attachEnterKeyListener("removeUserModal", "deleteUserBtn"); attachEnterKeyListener("removeUserModal", "deleteUserBtn");
attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn"); attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
document.querySelector(".header-buttons").style.visibility = "visible"; document.querySelector(".header-buttons").style.visibility = "visible";
// 3) Persist auth flags (unchanged) // 3) Persist auth flags (unchanged)
@@ -271,9 +320,9 @@ export async function updateAuthenticatedUI(data) {
localStorage.setItem("username", data.username); localStorage.setItem("username", data.username);
} }
if (typeof data.folderOnly !== "undefined") { if (typeof data.folderOnly !== "undefined") {
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false"); localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", data.readOnly ? "true" : "false"); localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
localStorage.setItem("disableUpload",data.disableUpload? "true" : "false"); localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
} }
// 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage // 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage
@@ -282,7 +331,7 @@ export async function updateAuthenticatedUI(data) {
// 5) Build / update header buttons // 5) Build / update header buttons
const headerButtons = document.querySelector(".header-buttons"); const headerButtons = document.querySelector(".header-buttons");
const firstButton = headerButtons.firstElementChild; const firstButton = headerButtons.firstElementChild;
// a) restore-from-trash for admins // a) restore-from-trash for admins
if (data.isAdmin) { if (data.isAdmin) {
@@ -290,8 +339,8 @@ export async function updateAuthenticatedUI(data) {
if (!r) { if (!r) {
r = document.createElement("button"); r = document.createElement("button");
r.id = "restoreFilesBtn"; r.id = "restoreFilesBtn";
r.classList.add("btn","btn-warning"); r.classList.add("btn", "btn-warning");
r.setAttribute("data-i18n-title","trash_restore_delete"); r.setAttribute("data-i18n-title", "trash_restore_delete");
r.innerHTML = '<i class="material-icons">restore_from_trash</i>'; r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
if (firstButton) insertAfter(r, firstButton); if (firstButton) insertAfter(r, firstButton);
else headerButtons.appendChild(r); else headerButtons.appendChild(r);
@@ -308,8 +357,8 @@ export async function updateAuthenticatedUI(data) {
if (!a) { if (!a) {
a = document.createElement("button"); a = document.createElement("button");
a.id = "adminPanelBtn"; a.id = "adminPanelBtn";
a.classList.add("btn","btn-info"); a.classList.add("btn", "btn-info");
a.setAttribute("data-i18n-title","admin_panel"); a.setAttribute("data-i18n-title", "admin_panel");
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>'; a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
insertAfter(a, document.getElementById("restoreFilesBtn")); insertAfter(a, document.getElementById("restoreFilesBtn"));
a.addEventListener("click", openAdminPanel); a.addEventListener("click", openAdminPanel);
@@ -330,19 +379,19 @@ export async function updateAuthenticatedUI(data) {
: `<i class="material-icons">account_circle</i>`; : `<i class="material-icons">account_circle</i>`;
// fallback username if missing // fallback username if missing
const usernameText = data.username const usernameText = data.username
|| localStorage.getItem("username") || localStorage.getItem("username")
|| ""; || "";
if (!dd) { if (!dd) {
dd = document.createElement("div"); dd = document.createElement("div");
dd.id = "userDropdown"; dd.id = "userDropdown";
dd.classList.add("user-dropdown"); dd.classList.add("user-dropdown");
// toggle button // toggle button
const toggle = document.createElement("button"); const toggle = document.createElement("button");
toggle.id = "userDropdownToggle"; toggle.id = "userDropdownToggle";
toggle.classList.add("btn","btn-user"); toggle.classList.add("btn", "btn-user");
toggle.setAttribute("title", t("user_settings")); toggle.setAttribute("title", t("user_settings"));
toggle.innerHTML = ` toggle.innerHTML = `
${avatarHTML} ${avatarHTML}
@@ -464,6 +513,14 @@ function checkAuthentication(showLoginToast = true) {
} }
updateAuthenticatedUI(data); updateAuthenticatedUI(data);
return data; return data;
// at the end of updateAuthenticatedUI(data)
if (!window.__FR_FLAGS?.initialized && typeof initializeApp === 'function') {
initializeApp();
window.__FR_FLAGS.initialized = true;
}
if (typeof applyTranslations === 'function') applyTranslations();
if (typeof updateLoginOptionsUIFromStorage === 'function') updateLoginOptionsUIFromStorage();
} else { } else {
const overlay = document.getElementById('loadingOverlay'); const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.remove(); if (overlay) overlay.remove();
@@ -484,53 +541,162 @@ function checkAuthentication(showLoginToast = true) {
} }
/* ----------------- Authentication Submission ----------------- */ /* ----------------- Authentication Submission ----------------- */
async function primeCsrfStrict() {
const r = await fetch('/api/auth/token.php', { credentials: 'include' });
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.csrf_token) throw new Error('CSRF missing');
window.csrfToken = j.csrf_token;
const m = document.querySelector('meta[name="csrf-token"]');
if (m) m.content = j.csrf_token;
}
function toFormBody(obj) {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(obj || {})) p.set(k, v == null ? '' : String(v));
return p.toString();
}
async function safeJson(res) {
const ct = res.headers.get('content-type') || '';
if (!/application\/json/i.test(ct)) return null;
try { return await res.clone().json(); } catch { return null; }
}
async function sniffTOTP(res, bodyMaybe) {
if (res.headers.get('X-TOTP-Required') === '1') return true;
if (res.redirected && /[?&]totp_required=1\b/.test(res.url)) return true;
const body = bodyMaybe ?? await safeJson(res);
if (body && (body.totp_required || body.error === 'TOTP_REQUIRED')) return true;
try {
const txt = await res.clone().text();
if (/\btotp_required\s*=\s*1\b/i.test(txt)) return true;
} catch { }
return false;
}
async function isAuthedNow() {
try {
const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
const j = await r.json().catch(() => ({}));
return !!j.authenticated;
} catch { return false; }
}
function rafTick(times = 2) {
return new Promise(res => {
const step = () => { if (--times <= 0) res(); else requestAnimationFrame(step); };
requestAnimationFrame(step);
});
}
async function fetchAuthSnapshot() {
try {
const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
return await r.json();
} catch { return {}; }
}
async function syncPermissionsToLocalStorage() {
try {
const r = await fetch('/api/getUserPermissions.php', { credentials: 'include' });
const perm = await r.json();
if (perm && typeof perm === 'object') {
localStorage.setItem('folderOnly', perm.folderOnly ? 'true' : 'false');
localStorage.setItem('readOnly', perm.readOnly ? 'true' : 'false');
localStorage.setItem('disableUpload', perm.disableUpload ? 'true' : 'false');
}
} catch { /* non-fatal */ }
}
// ——— main ———
let __loginInFlight = false;
async function submitLogin(data) { async function submitLogin(data) {
setLastLoginData(data); if (__loginInFlight) return;
window.__lastLoginData = data; __loginInFlight = true;
const payload = {
username: String(data.username || '').trim(),
password: String(data.password || '').trim(),
remember_me: data.remember_me ? 1 : 0
};
setLastLoginData(payload);
window.__lastLoginData = payload;
try { try {
// ─── 1) Get CSRF for the initial auth call ─── await primeCsrfStrict();
let res = await fetch("/api/auth/token.php", { credentials: "include" });
if (!res.ok) throw new Error("Could not fetch CSRF token");
window.csrfToken = (await res.json()).csrf_token;
// ─── 2) Send credentials ─── // Attempt #1 — JSON
const response = await sendRequest( let res = await fetchWithCsrf('/api/auth/auth.php', {
"/api/auth/auth.php", method: 'POST',
"POST", credentials: 'include',
data, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
{ "X-CSRF-Token": window.csrfToken } body: JSON.stringify(payload)
); });
let body = await safeJson(res);
// ─── 3a) Full login (no TOTP) ─── // TOTP requested?
if (response.success || response.status === "ok") { if (await sniffTOTP(res, body)) {
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!"); try { await primeCsrfStrict(); } catch { }
// … fetch permissions & reload … window.pendingTOTP = true;
try { try {
const perm = await sendRequest("/api/getUserPermissions.php", "GET"); const auth = await import('/js/auth.js?v={{APP_QVER}}');
if (perm && typeof perm === "object") { if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false");
}
} catch { } } catch { }
return window.location.reload(); return;
} }
// ─── 3b) TOTP required ─── // Full success (no TOTP)
if (response.totp_required) { if (body && (body.success || body.status === 'ok' || body.authenticated)) {
// **Refresh** CSRF before the TOTP verify call
res = await fetch("/api/auth/token.php", { credentials: "include" }); await syncPermissionsToLocalStorage();
if (res.ok) { return afterLogin();
window.csrfToken = (await res.json()).csrf_token;
}
// now open the modal—any totp_verify fetch from here on will use the new token
return openTOTPLoginModal();
} }
// ─── 3c) Too many attempts ─── // Cookie set but non-JSON body — double check session
if (response.error && response.error.includes("Too many failed login attempts")) { if (!body && await isAuthedNow()) {
showToast(response.error);
await syncPermissionsToLocalStorage();
return afterLogin();
}
// Attempt #2 — form fallback
res = await fetchWithCsrf('/api/auth/auth.php', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
body: toFormBody(payload)
});
body = await safeJson(res);
if (await sniffTOTP(res, body)) {
try { await primeCsrfStrict(); } catch { }
window.pendingTOTP = true;
try {
const auth = await import('/js/auth.js?v={{APP_QVER}}');
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
} catch { }
return;
}
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
await syncPermissionsToLocalStorage();
return afterLogin();
}
if (!body && await isAuthedNow()) {
await syncPermissionsToLocalStorage();
return afterLogin();
}
// Rate limit still respected
if (body?.error && /Too many failed login attempts/i.test(body.error)) {
showToast(body.error);
const btn = document.querySelector("#authForm button[type='submit']"); const btn = document.querySelector("#authForm button[type='submit']");
if (btn) { if (btn) {
btn.disabled = true; btn.disabled = true;
@@ -542,12 +708,12 @@ async function submitLogin(data) {
return; return;
} }
// ─── 3d) Other failures ─── showToast('Login failed' + (body?.error ? `: ${body.error}` : ''));
showToast("Login failed: " + (response.error || "Unknown error"));
} catch (err) { } catch (e) {
const msg = err.message || err.error || "Unknown error"; showToast('Login failed: ' + (e.message || 'Unknown error'));
showToast(`Login failed: ${msg}`); } finally {
__loginInFlight = false;
} }
} }
@@ -763,4 +929,4 @@ document.addEventListener("DOMContentLoaded", function () {
} }
}); });
export { initAuth, checkAuthentication }; export { initAuth, checkAuthentication, openTOTPLoginModal };

View File

@@ -1,7 +1,7 @@
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
import { t, applyTranslations, setLocale } from './i18n.js'; import { t, applyTranslations, setLocale } from './i18n.js?v={{APP_QVER}}';
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js'; import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js?v={{APP_QVER}}';
let lastLoginData = null; let lastLoginData = null;
export function setLastLoginData(data) { export function setLastLoginData(data) {
@@ -195,7 +195,6 @@ export async function openUserPanel() {
color: ${isDark ? '#e0e0e0' : '#000'}; color: ${isDark ? '#e0e0e0' : '#000'};
padding: 20px; padding: 20px;
max-width: 600px; width:90%; max-width: 600px; width:90%;
border-radius: 8px;
overflow-y: auto; max-height: 500px; overflow-y: auto; max-height: 500px;
border: ${isDark ? '1px solid #444' : '1px solid #ccc'}; border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
box-sizing: border-box; box-sizing: border-box;

31
public/js/defer-css.js Normal file
View File

@@ -0,0 +1,31 @@
// /public/js/defer-css.js
// Promote preloaded styles to real stylesheets (CSP-safe) and expose a load promise.
(function () {
if (window.__CSS_PROMISE__) return;
var loads = [];
// Promote <link rel="preload" as="style"> IN-PLACE
var preloads = document.querySelectorAll('link[rel="preload"][as="style"]');
for (var i = 0; i < preloads.length; i++) {
var l = preloads[i];
// resolve when it finishes loading as a stylesheet
loads.push(new Promise(function (res) { l.addEventListener('load', res, { once: true }); }));
l.rel = 'stylesheet';
if (!l.media || l.media === 'print') l.media = 'all'; // be explicit
l.removeAttribute('as'); // keep some engines happy about "used" preload
}
// Also wait for any existing <link rel="stylesheet"> that haven't finished yet
var styles = document.querySelectorAll('link[rel="stylesheet"]');
for (var j = 0; j < styles.length; j++) {
var s = styles[j];
if (s.sheet) continue; // already applied
loads.push(new Promise(function (res) { s.addEventListener('load', res, { once: true }); }));
}
// Safari quirk: nudge layout so promoted sheets apply immediately
void document.documentElement.offsetHeight;
window.__CSS_PROMISE__ = Promise.all(loads);
})();

View File

@@ -1,6 +1,6 @@
// domUtils.js // domUtils.js
import { t } from './i18n.js'; import { t } from './i18n.js?v={{APP_QVER}}';
import { openDownloadModal } from './fileActions.js'; import { openDownloadModal } from './fileActions.js?v={{APP_QVER}}';
// Basic DOM Helpers // Basic DOM Helpers
export function toggleVisibility(elementId, shouldShow) { export function toggleVisibility(elementId, shouldShow) {
@@ -156,7 +156,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
export function buildFileTableHeader(sortOrder) { export function buildFileTableHeader(sortOrder) {
return ` return `
<table class="table"> <table class="table filr-table table-hover table-striped">
<thead> <thead>
<tr> <tr>
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th> <th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
@@ -283,9 +283,9 @@ export function updateRowHighlight(checkbox) {
const row = checkbox.closest('tr'); const row = checkbox.closest('tr');
if (!row) return; if (!row) return;
if (checkbox.checked) { if (checkbox.checked) {
row.classList.add('row-selected'); row.classList.add('row-selected', 'selected');
} else { } else {
row.classList.remove('row-selected'); row.classList.remove('row-selected', 'selected');
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
// fileActions.js // fileActions.js
import { showToast, attachEnterKeyListener } from './domUtils.js'; import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { formatFolderName } from './fileListView.js'; import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
import { t } from './i18n.js'; import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
export function handleDeleteSelected(e) { export function handleDeleteSelected(e) {
e.preventDefault(); e.preventDefault();
@@ -12,7 +13,6 @@ export function handleDeleteSelected(e) {
showToast("no_files_selected"); showToast("no_files_selected");
return; return;
} }
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value); window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
const count = window.filesToDelete.length; const count = window.filesToDelete.length;
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count }); document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
@@ -20,6 +20,52 @@ export function handleDeleteSelected(e) {
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles"); 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 () { document.addEventListener("DOMContentLoaded", function () {
const cancelDelete = document.getElementById("cancelDeleteFiles"); const cancelDelete = document.getElementById("cancelDeleteFiles");
if (cancelDelete) { if (cancelDelete) {
@@ -31,6 +77,7 @@ document.addEventListener("DOMContentLoaded", function () {
const confirmDelete = document.getElementById("confirmDeleteFiles"); const confirmDelete = document.getElementById("confirmDeleteFiles");
if (confirmDelete) { if (confirmDelete) {
confirmDelete.setAttribute("data-default", "");
confirmDelete.addEventListener("click", function () { confirmDelete.addEventListener("click", function () {
fetch("/api/file/deleteFiles.php", { fetch("/api/file/deleteFiles.php", {
method: "POST", method: "POST",
@@ -46,6 +93,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) { if (data.success) {
showToast("Selected files deleted successfully!"); showToast("Selected files deleted successfully!");
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
refreshFolderIcon(window.currentFolder);
} else { } else {
showToast("Error: " + (data.error || "Could not delete files")); showToast("Error: " + (data.error || "Could not delete files"));
} }
@@ -118,7 +166,7 @@ export async function handleCreateFile(e) {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type':'application/json', 'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken 'X-CSRF-Token': window.csrfToken
}, },
// ⚠️ must send `name`, not `filename` // ⚠️ must send `name`, not `filename`
@@ -128,6 +176,7 @@ export async function handleCreateFile(e) {
if (!js.success) throw new Error(js.error); if (!js.success) throw new Error(js.error);
showToast(t('file_created')); showToast(t('file_created'));
loadFileList(folder); loadFileList(folder);
refreshFolderIcon(folder);
} catch (err) { } catch (err) {
showToast(err.message || t('error_creating_file')); showToast(err.message || t('error_creating_file'));
} finally { } finally {
@@ -138,7 +187,7 @@ export async function handleCreateFile(e) {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const cancel = document.getElementById('cancelCreateFile'); const cancel = document.getElementById('cancelCreateFile');
const confirm = document.getElementById('confirmCreateFile'); 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); if (confirm) confirm.addEventListener('click', handleCreateFile);
}); });
@@ -264,7 +313,7 @@ document.addEventListener("DOMContentLoaded", () => {
const cancelZipBtn = document.getElementById("cancelDownloadZip"); const cancelZipBtn = document.getElementById("cancelDownloadZip");
const confirmZipBtn = document.getElementById("confirmDownloadZip"); const confirmZipBtn = document.getElementById("confirmDownloadZip");
const cancelCreate = document.getElementById('cancelCreateFile'); const cancelCreate = document.getElementById('cancelCreateFile');
if (cancelCreate) { if (cancelCreate) {
cancelCreate.addEventListener('click', () => { cancelCreate.addEventListener('click', () => {
document.getElementById('createFileModal').style.display = 'none'; document.getElementById('createFileModal').style.display = 'none';
@@ -299,12 +348,13 @@ document.addEventListener("DOMContentLoaded", () => {
} }
showToast(t('file_created_successfully')); showToast(t('file_created_successfully'));
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
refreshFolderIcon(folder);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
showToast(err.message || t('error_creating_file')); showToast(err.message || t('error_creating_file'));
} }
}); });
attachEnterKeyListener('createFileModal','confirmCreateFile'); attachEnterKeyListener('createFileModal', 'confirmCreateFile');
} }
// 1) Cancel button hides the name modal // 1) Cancel button hides the name modal
@@ -316,66 +366,191 @@ document.addEventListener("DOMContentLoaded", () => {
// 2) Confirm button kicks off the zip+download // 2) Confirm button kicks off the zip+download
if (confirmZipBtn) { if (confirmZipBtn) {
confirmZipBtn.setAttribute("data-default", "");
confirmZipBtn.addEventListener("click", async () => { confirmZipBtn.addEventListener("click", async () => {
// a) Validate ZIP filename // a) Validate ZIP filename
let zipName = document.getElementById("zipFileNameInput").value.trim(); let zipName = document.getElementById("zipFileNameInput").value.trim();
if (!zipName) { if (!zipName) { showToast("Please enter a name for the zip file."); return; }
showToast("Please enter a name for the zip file."); if (!zipName.toLowerCase().endsWith(".zip")) zipName += ".zip";
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"; zipNameModal.style.display = "none";
progressModal.style.display = "block"; 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"); const titleEl = document.getElementById("downloadProgressTitle");
if (titleEl) titleEl.textContent = `Preparing ${zipName}`; if (titleEl) titleEl.textContent = `Preparing ${zipName}`;
try { // d) Queue the job
// d) POST and await the ZIP blob const res = await fetch("/api/file/downloadZip.php", {
const res = await fetch("/api/file/downloadZip.php", { method: "POST",
method: "POST", credentials: "include",
credentials: "include", headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
headers: { body: JSON.stringify({ folder: window.currentFolder || "root", files: window.filesToDownload })
"Content-Type": "application/json", });
"X-CSRF-Token": window.csrfToken const jsr = await res.json().catch(() => ({}));
}, if (!res.ok || !jsr.ok) {
body: JSON.stringify({ const msg = (jsr && jsr.error) ? jsr.error : `Status ${res.status}`;
folder: window.currentFolder || "root", throw new Error(msg);
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";
} }
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 = [];
}); });
} }
}); });
@@ -478,6 +653,7 @@ document.addEventListener("DOMContentLoaded", function () {
} }
const confirmCopy = document.getElementById("confirmCopyFiles"); const confirmCopy = document.getElementById("confirmCopyFiles");
if (confirmCopy) { if (confirmCopy) {
confirmCopy.setAttribute("data-default", "");
confirmCopy.addEventListener("click", function () { confirmCopy.addEventListener("click", function () {
const targetFolder = document.getElementById("copyTargetFolder").value; const targetFolder = document.getElementById("copyTargetFolder").value;
if (!targetFolder) { if (!targetFolder) {
@@ -506,6 +682,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) { if (data.success) {
showToast("Selected files copied successfully!", 5000); showToast("Selected files copied successfully!", 5000);
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
} else { } else {
showToast("Error: " + (data.error || "Could not copy files"), 5000); showToast("Error: " + (data.error || "Could not copy files"), 5000);
} }
@@ -529,6 +706,7 @@ document.addEventListener("DOMContentLoaded", function () {
} }
const confirmMove = document.getElementById("confirmMoveFiles"); const confirmMove = document.getElementById("confirmMoveFiles");
if (confirmMove) { if (confirmMove) {
confirmMove.setAttribute("data-default", "");
confirmMove.addEventListener("click", function () { confirmMove.addEventListener("click", function () {
const targetFolder = document.getElementById("moveTargetFolder").value; const targetFolder = document.getElementById("moveTargetFolder").value;
if (!targetFolder) { if (!targetFolder) {
@@ -557,6 +735,8 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) { if (data.success) {
showToast("Selected files moved successfully!"); showToast("Selected files moved successfully!");
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
refreshFolderIcon(window.currentFolder);
} else { } else {
showToast("Error: " + (data.error || "Could not move files")); showToast("Error: " + (data.error || "Could not move files"));
} }
@@ -598,6 +778,7 @@ document.addEventListener("DOMContentLoaded", () => {
const submitBtn = document.getElementById("submitRenameFile"); const submitBtn = document.getElementById("submitRenameFile");
if (submitBtn) { if (submitBtn) {
submitBtn.setAttribute("data-default", "");
submitBtn.addEventListener("click", function () { submitBtn.addEventListener("click", function () {
const newName = document.getElementById("newFileName").value.trim(); const newName = document.getElementById("newFileName").value.trim();
if (!newName || newName === window.fileToRename) { if (!newName || newName === window.fileToRename) {
@@ -689,10 +870,11 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('createBtn'); const btn = document.getElementById('createBtn');
const menu = document.getElementById('createMenu'); const menu = document.getElementById('createMenu');
const fileOpt = document.getElementById('createFileOption'); const fileOpt = document.getElementById('createFileOption');
const folderOpt= document.getElementById('createFolderOption'); const folderOpt = document.getElementById('createFolderOption');
const uploadOpt = document.getElementById('uploadOption'); // NEW
// Toggle dropdown on click // Toggle dropdown on click
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
@@ -717,6 +899,32 @@ document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('click', () => { document.addEventListener('click', () => {
menu.style.display = 'none'; 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; window.renameFile = renameFile;

View File

@@ -1,125 +1,164 @@
// fileDragDrop.js // fileDragDrop.js
import { showToast } from './domUtils.js'; import { showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
export function fileDragStartHandler(event) { /* ---------------- helpers ---------------- */
const row = event.currentTarget; function getRowEl(el) {
let fileNames = []; return el?.closest('tr[data-file-name], .gallery-card[data-file-name]') || null;
}
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked"); function getNameFromAny(el) {
if (selectedCheckboxes.length > 1) { const row = getRowEl(el);
selectedCheckboxes.forEach(chk => { if (!row) return null;
const parentRow = chk.closest("tr"); // 1) canonical
if (parentRow) { const n = row.getAttribute('data-file-name');
const cell = parentRow.querySelector("td:nth-child(2)"); if (n) return n;
if (cell) { // 2) filename-only span
let rawName = cell.textContent.trim(); const span = row.querySelector('.filename-text');
const tagContainer = cell.querySelector(".tag-badges"); if (span) return span.textContent.trim();
if (tagContainer) { return null;
const tagText = tagContainer.innerText.trim(); }
if (rawName.endsWith(tagText)) { function getSelectedFileNames() {
rawName = rawName.slice(0, -tagText.length).trim(); const boxes = Array.from(document.querySelectorAll('#fileList .file-checkbox:checked'));
} const names = boxes.map(cb => getNameFromAny(cb)).filter(Boolean);
} // de-dup just in case
fileNames.push(rawName); return Array.from(new Set(names));
} }
} function makeDragImage(labelText, iconName = 'insert_drive_file') {
}); const wrap = document.createElement('div');
} else { Object.assign(wrap.style, {
const fileNameCell = row.querySelector("td:nth-child(2)"); display: 'inline-flex',
if (fileNameCell) { maxWidth: '420px',
let rawName = fileNameCell.textContent.trim(); padding: '6px 10px',
const tagContainer = fileNameCell.querySelector(".tag-badges"); backgroundColor: '#333',
if (tagContainer) { color: '#fff',
const tagText = tagContainer.innerText.trim(); border: '1px solid #555',
if (rawName.endsWith(tagText)) { borderRadius: '6px',
rawName = rawName.slice(0, -tagText.length).trim(); alignItems: 'center',
} gap: '6px',
} boxShadow: '2px 2px 6px rgba(0,0,0,0.3)',
fileNames.push(rawName); fontSize: '12px',
} pointerEvents: 'none'
} });
const icon = document.createElement('span');
if (fileNames.length === 0) return; icon.className = 'material-icons';
icon.textContent = iconName;
const dragData = fileNames.length === 1 const label = document.createElement('span');
? { fileName: fileNames[0], sourceFolder: window.currentFolder || "root" } // trim long single-name labels
: { files: fileNames, sourceFolder: window.currentFolder || "root" }; const txt = String(labelText || '');
label.textContent = txt.length > 60 ? (txt.slice(0, 57) + '…') : txt;
event.dataTransfer.setData("application/json", JSON.stringify(dragData)); wrap.appendChild(icon);
wrap.appendChild(label);
let dragImage = document.createElement("div"); document.body.appendChild(wrap);
dragImage.style.display = "inline-flex"; return wrap;
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);
} }
/* ---------------- 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) { export function folderDragOverHandler(event) {
event.preventDefault(); event.preventDefault();
event.currentTarget.classList.add("drop-hover"); event.currentTarget.classList.add('drop-hover');
} }
export function folderDragLeaveHandler(event) { 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.preventDefault();
event.currentTarget.classList.remove("drop-hover"); event.currentTarget.classList.remove('drop-hover');
const dropFolder = event.currentTarget.getAttribute("data-folder");
let dragData; const dropFolder = event.currentTarget.getAttribute('data-folder')
|| event.currentTarget.getAttribute('data-dest-folder')
|| 'root';
// parse drag payload
let dragData = null;
try { try {
dragData = JSON.parse(event.dataTransfer.getData("application/json")); const raw = event.dataTransfer.getData('application/json') || '{}';
} catch (e) { dragData = JSON.parse(raw);
console.error("Invalid drag data"); } catch {
// ignore
}
if (!dragData) {
showToast('Invalid drag data.');
return; return;
} }
if (!dragData || !dragData.fileName) return;
fetch("/api/file/moveFiles.php", { // normalize names
method: "POST", let names = Array.isArray(dragData.files) ? dragData.files.slice()
credentials: "include", : dragData.fileName ? [dragData.fileName]
headers: { : [];
"Content-Type": "application/json", names = names.filter(v => typeof v === 'string' && v.length > 0);
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
}, if (names.length === 0) {
body: JSON.stringify({ showToast('No files to move.');
source: dragData.sourceFolder, return;
files: [dragData.fileName], }
destination: dropFolder
}) const sourceFolder = dragData.sourceFolder || (window.currentFolder || 'root');
}) if (dropFolder === sourceFolder) {
.then(response => response.json()) showToast('Source and destination are the same.');
.then(data => { return;
if (data.success) { }
showToast(`File "${dragData.fileName}" moved successfully to ${dropFolder}!`);
loadFileList(dragData.sourceFolder); // POST move
} else { try {
showToast("Error moving file: " + (data.error || "Unknown error")); const res = await fetch('/api/file/moveFiles.php', {
} method: 'POST',
}) credentials: 'include',
.catch(error => { headers: {
console.error("Error moving file via drop:", error); 'Content-Type': 'application/json',
showToast("Error moving file."); '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

@@ -1,43 +1,59 @@
// fileEditor.js // fileEditor.js
import { escapeHTML, showToast } from './domUtils.js'; import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { t } from './i18n.js'; import { t } from './i18n.js?v={{APP_QVER}}';
import { buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
// thresholds for editor behavior // thresholds for editor behavior
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
// Lazy-load CodeMirror modes on demand // ==== CodeMirror lazy loader ===============================================
//const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/"; const CM_BASE = "/vendor/codemirror/5.65.5/";
const CM_LOCAL = "/vendor/codemirror/5.65.5/";
// Stamp-friendly helpers (the stamper will replace {{APP_QVER}})
const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`;
const CORE = {
js: coreUrl("codemirror.min.js"),
css: coreUrl("codemirror.min.css"),
themeCss: coreUrl("theme/material-darker.min.css"),
};
// Which mode file to load for a given name/mime // Which mode file to load for a given name/mime
const MODE_URL = { const MODE_URL = {
// core/common // core/common
"xml": "mode/xml/xml.min.js", "xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
"css": "mode/css/css.min.js", "css": "mode/css/css.min.js?v={{APP_QVER}}",
"javascript": "mode/javascript/javascript.min.js", "javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}",
// meta / combos // meta / combos
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js", "htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
"application/x-httpd-php": "mode/php/php.min.js", "application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}",
// docs / data // docs / data
"markdown": "mode/markdown/markdown.min.js", "markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
"yaml": "mode/yaml/yaml.min.js", "yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
"properties": "mode/properties/properties.min.js", "properties": "mode/properties/properties.min.js?v={{APP_QVER}}",
"sql": "mode/sql/sql.min.js", "sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
// shells // shells
"shell": "mode/shell/shell.min.js", "shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
// languages // languages
"python": "mode/python/python.min.js", "python": "mode/python/python.min.js?v={{APP_QVER}}",
"text/x-csrc": "mode/clike/clike.min.js", "text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-c++src": "mode/clike/clike.min.js", "text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-java": "mode/clike/clike.min.js", "text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-csharp": "mode/clike/clike.min.js", "text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-kotlin": "mode/clike/clike.min.js" "text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
};
// Mode dependency graph
const MODE_DEPS = {
"htmlmixed": ["xml", "javascript", "css"],
"application/x-httpd-php": ["htmlmixed", "text/x-csrc"], // php overlays + clike bits
"markdown": ["xml"]
}; };
// Map any mime/alias to the key we use in MODE_URL // Map any mime/alias to the key we use in MODE_URL
@@ -49,62 +65,401 @@ function normalizeModeName(modeOption) {
return name; return name;
} }
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever // ---- ONLYOFFICE integration -----------------------------------------------
function loadScriptOnce(url) { function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
return new Promise((resolve, reject) => {
const ver = (window.APP_VERSION ?? 'dev').replace(/^v/, ''); // "v1.6.9" -> "1.6.9"
const withQS = url + '?v=' + ver;
const key = `cm:${withQS}`; // Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
let s = document.querySelector(`script[data-key="${key}"]`); let __ooCaps = { enabled: false, exts: new Set(), fetched: false, docsOrigin: null };
if (s) {
if (s.dataset.loaded === "1") return resolve(); async function fetchOnlyOfficeCapsOnce() {
s.addEventListener("load", resolve); if (__ooCaps.fetched) return __ooCaps;
s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`))); try {
return; 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
} }
s = document.createElement("script"); } catch { /* ignore; keep defaults */ }
s.src = withQS; __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, 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.async = true;
s.dataset.key = key; s.onload = () => { clearTimeout(timer); _loadedScripts.add(url); resolve(); };
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); }); s.onerror = () => { clearTimeout(timer); reject(new Error(`Load failed: ${url}`)); };
s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`)));
document.head.appendChild(s); document.head.appendChild(s);
}); });
} }
function loadCssOnce(href) {
return new Promise((resolve, reject) => {
if (_loadedCss.has(href)) return resolve();
const l = document.createElement("link");
l.rel = "stylesheet";
l.href = href;
l.onload = () => { _loadedCss.add(href); resolve(); };
l.onerror = () => reject(new Error(`Load failed: ${href}`));
document.head.appendChild(l);
});
}
async function ensureCore() {
if (_corePromise) return _corePromise;
_corePromise = (async () => {
// load CSS first to avoid FOUC
await loadCssOnce(CORE.css);
await loadCssOnce(CORE.themeCss);
if (!window.CodeMirror) {
await loadScriptOnce(CORE.js);
}
})();
return _corePromise;
}
async function loadSingleMode(name) {
const rel = MODE_URL[name];
if (!rel) return;
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
await loadScriptOnce(url);
}
function isModeRegistered(name) {
return !!(
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) ||
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name])
);
}
async function ensureModeLoaded(modeOption) { async function ensureModeLoaded(modeOption) {
if (!window.CodeMirror) return; await ensureCore();
const name = normalizeModeName(modeOption); const name = normalizeModeName(modeOption);
if (!name) return; if (!name) return;
if (isModeRegistered(name)) return;
const isRegistered = () => const deps = MODE_DEPS[name] || [];
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) || for (const d of deps) {
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name]); if (!isModeRegistered(d)) await loadSingleMode(d);
if (isRegistered()) return;
const url = MODE_URL[name];
if (!url) return; // unknown -> stay in text/plain
// Dependencies
if (name === "htmlmixed") {
await Promise.all([
ensureModeLoaded("xml"),
ensureModeLoaded("css"),
ensureModeLoaded("javascript")
]);
} }
if (name === "application/x-httpd-php") { await loadSingleMode(name);
await ensureModeLoaded("htmlmixed");
}
await loadScriptOnce(CM_LOCAL + url);
} }
// Public helper for callers (we keep your existing function name in use):
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) { function getModeForFile(fileName) {
const dot = fileName.lastIndexOf("."); const dot = fileName.lastIndexOf(".");
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : ""; const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
@@ -164,29 +519,48 @@ function observeModalResize(modal) {
} }
export { observeModalResize }; export { observeModalResize };
export function editFile(fileName, folder) { export async function editFile(fileName, folder) {
// destroy any previous editor // destroy any previous editor
let existingEditor = document.getElementById("editorContainer"); let existingEditor = document.getElementById("editorContainer");
if (existingEditor) existingEditor.remove(); if (existingEditor) existingEditor.remove();
const folderUsed = folder || window.currentFolder || "root"; const folderUsed = folder || window.currentFolder || "root";
const folderPath = folderUsed === "root" const fileUrl = buildPreviewUrl(folderUsed, fileName);
? "uploads/"
: "uploads/" + folderUsed.split("/").map(encodeURIComponent).join("/") + "/";
const fileUrl = folderPath + encodeURIComponent(fileName) + "?t=" + new Date().getTime();
fetch(fileUrl, { method: "HEAD" }) if (await shouldUseOnlyOffice(fileName)) {
.then(response => { await openOnlyOffice(fileName, folderUsed);
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length"); return;
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null; }
// Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET.
async function probeSize(url) {
try {
const h = await fetch(url, { method: "HEAD", credentials: "include" });
const len = h.headers.get("content-length") ?? h.headers.get("Content-Length");
if (len && !Number.isNaN(parseInt(len, 10))) return parseInt(len, 10);
} catch { }
try {
const r = await fetch(url, {
method: "GET",
headers: { Range: "bytes=0-0" },
credentials: "include"
});
// Content-Range: bytes 0-0/12345
const cr = r.headers.get("content-range") ?? r.headers.get("Content-Range");
const m = cr && cr.match(/\/(\d+)\s*$/);
if (m) return parseInt(m[1], 10);
} catch { }
return null;
}
probeSize(fileUrl)
.then(sizeBytes => {
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) { if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
showToast("This file is larger than 10 MB and cannot be edited in the browser."); showToast("This file is larger than 10 MB and cannot be edited in the browser.");
throw new Error("File too large."); throw new Error("File too large.");
} }
return response; return fetch(fileUrl, { credentials: "include" });
}) })
.then(() => fetch(fileUrl))
.then(response => { .then(response => {
if (!response.ok) throw new Error("HTTP error! Status: " + response.status); if (!response.ok) throw new Error("HTTP error! Status: " + response.status);
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length"); const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
@@ -215,7 +589,7 @@ export function editFile(fileName, folder) {
</div> </div>
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea> <textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
<div class="editor-footer"> <div class="editor-footer">
<button id="saveBtn" class="btn btn-primary" disabled>${t("save")}</button> <button id="saveBtn" class="btn btn-primary" data-default disabled>${t("save")} </button>
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button> <button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
</div> </div>
`; `;
@@ -238,28 +612,28 @@ export function editFile(fileName, folder) {
// Keep buttons responsive even before editor exists // Keep buttons responsive even before editor exists
const decBtn = document.getElementById("decreaseFont"); const decBtn = document.getElementById("decreaseFont");
const incBtn = document.getElementById("increaseFont"); const incBtn = document.getElementById("increaseFont");
decBtn.addEventListener("click", () => {}); decBtn.addEventListener("click", () => { });
incBtn.addEventListener("click", () => {}); incBtn.addEventListener("click", () => { });
// Theme + mode selection // Theme + mode selection
const isDarkMode = document.body.classList.contains("dark-mode"); const isDarkMode = document.body.classList.contains("dark-mode");
const theme = isDarkMode ? "material-darker" : "default"; const theme = isDarkMode ? "material-darker" : "default";
const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName); const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName);
// Helper to check whether a mode is currently registered // Start core+mode loading (dont block closing)
const modeName = typeof desiredMode === "string" ? desiredMode : (desiredMode && desiredMode.name); const modePromise = (async () => {
const isModeRegistered = () => await ensureCore(); // load CM core + CSS
(window.CodeMirror?.modes && window.CodeMirror.modes[modeName]) || if (!forcePlainText) {
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[modeName]); await ensureModeLoaded(desiredMode); // then load the needed mode + deps
}
// Start mode loading (dont block closing) })();
const modePromise = ensureModeLoaded(desiredMode);
// Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available // Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available
const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS)); const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS));
Promise.race([modePromise, timeout]).then(() => { Promise.race([modePromise, timeout]).then(() => {
if (canceled) return; if (canceled) return;
if (!window.CodeMirror) { if (!window.CodeMirror) {
// Core not present: keep plain <textarea>; enable Save and bail gracefully // Core not present: keep plain <textarea>; enable Save and bail gracefully
document.getElementById("saveBtn").disabled = false; document.getElementById("saveBtn").disabled = false;
@@ -267,39 +641,39 @@ export function editFile(fileName, folder) {
return; return;
} }
const initialMode = (forcePlainText || !isModeRegistered()) ? "text/plain" : desiredMode; const normName = normalizeModeName(desiredMode) || "text/plain";
const cmOptions = { const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
lineNumbers: !forcePlainText,
mode: initialMode,
theme,
viewportMargin: forcePlainText ? 20 : Infinity,
lineWrapping: false
};
const editor = window.CodeMirror.fromTextArea( const cm = window.CodeMirror.fromTextArea(
document.getElementById("fileEditor"), document.getElementById("fileEditor"),
cmOptions {
lineNumbers: !forcePlainText,
mode: initialMode,
theme,
viewportMargin: forcePlainText ? 20 : Infinity,
lineWrapping: false
}
); );
window.currentEditor = editor; window.currentEditor = cm;
setTimeout(adjustEditorSize, 50); setTimeout(adjustEditorSize, 50);
observeModalResize(modal); observeModalResize(modal);
// Font controls (now that editor exists) // Font controls (now that editor exists)
let currentFontSize = 14; let currentFontSize = 14;
const wrapper = editor.getWrapperElement(); const wrapper = cm.getWrapperElement();
wrapper.style.fontSize = currentFontSize + "px"; wrapper.style.fontSize = currentFontSize + "px";
editor.refresh(); cm.refresh();
decBtn.addEventListener("click", function () { decBtn.addEventListener("click", function () {
currentFontSize = Math.max(8, currentFontSize - 2); currentFontSize = Math.max(8, currentFontSize - 2);
wrapper.style.fontSize = currentFontSize + "px"; wrapper.style.fontSize = currentFontSize + "px";
editor.refresh(); cm.refresh();
}); });
incBtn.addEventListener("click", function () { incBtn.addEventListener("click", function () {
currentFontSize = Math.min(32, currentFontSize + 2); currentFontSize = Math.min(32, currentFontSize + 2);
wrapper.style.fontSize = currentFontSize + "px"; wrapper.style.fontSize = currentFontSize + "px";
editor.refresh(); cm.refresh();
}); });
// Save // Save
@@ -312,19 +686,20 @@ export function editFile(fileName, folder) {
// Theme switch // Theme switch
function updateEditorTheme() { function updateEditorTheme() {
const isDark = document.body.classList.contains("dark-mode"); 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"); const toggle = document.getElementById("darkModeToggle");
if (toggle) toggle.addEventListener("click", updateEditorTheme); if (toggle) toggle.addEventListener("click", updateEditorTheme);
// If we started in plain text due to timeout, flip to the real mode once it arrives // If we started in plain text due to timeout, flip to the real mode once it arrives
modePromise.then(() => { modePromise.then(() => {
if (!canceled && !forcePlainText && isModeRegistered()) { if (!canceled && !forcePlainText) {
editor.setOption("mode", desiredMode); const nn = normalizeModeName(desiredMode);
if (nn && isModeRegistered(nn)) {
cm.setOption("mode", desiredMode);
}
} }
}).catch(() => { }).catch(() => { /* stay in plain text */ });
// If the mode truly fails to load, we just stay in plain text
});
}); });
}) })
.catch(error => { .catch(error => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
// fileManager.js // fileManager.js
import './fileListView.js'; import './fileListView.js?v={{APP_QVER}}';
import './filePreview.js'; import './filePreview.js?v={{APP_QVER}}';
import './fileEditor.js'; import './fileEditor.js?v={{APP_QVER}}';
import './fileDragDrop.js'; import './fileDragDrop.js?v={{APP_QVER}}';
import './fileMenu.js'; import './fileMenu.js?v={{APP_QVER}}';
import { initFileActions } from './fileActions.js'; import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
// Initialize file action buttons. // Initialize file action buttons.
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
@@ -14,7 +14,7 @@ document.addEventListener("DOMContentLoaded", function () {
// Attach folder drag-and-drop support for folder tree nodes. // Attach folder drag-and-drop support for folder tree nodes.
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".folder-option").forEach(el => { document.querySelectorAll(".folder-option").forEach(el => {
import('./fileDragDrop.js').then(module => { import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
el.addEventListener("dragover", module.folderDragOverHandler); el.addEventListener("dragover", module.folderDragOverHandler);
el.addEventListener("dragleave", module.folderDragLeaveHandler); el.addEventListener("dragleave", module.folderDragLeaveHandler);
el.addEventListener("drop", module.folderDropHandler); el.addEventListener("drop", module.folderDropHandler);
@@ -32,7 +32,7 @@ document.addEventListener("keydown", function(e) {
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked"); const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
if (selectedCheckboxes.length > 0) { if (selectedCheckboxes.length > 0) {
e.preventDefault(); e.preventDefault();
import('./fileActions.js').then(module => { import('./fileActions.js?v={{APP_QVER}}').then(module => {
module.handleDeleteSelected(new Event("click")); module.handleDeleteSelected(new Event("click"));
}); });
} }

View File

@@ -1,157 +1,246 @@
// fileMenu.js // fileMenu.js
import { updateRowHighlight, showToast } from './domUtils.js'; import { t } from './i18n.js?v={{APP_QVER}}';
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js'; import { updateRowHighlight } from './domUtils.js?v={{APP_QVER}}';
import { previewFile } from './filePreview.js'; import {
import { editFile } from './fileEditor.js'; handleDeleteSelected, handleCopySelected, handleMoveSelected,
import { canEditFile, fileData } from './fileListView.js'; handleDownloadZipSelected, handleExtractZipSelected,
import { openTagModal, openMultiTagModal } from './fileTags.js'; renameFile, openCreateFileModal
import { t } from './i18n.js'; } 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 { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
export function showFileContextMenu(x, y, menuItems) { const MENU_ID = 'fileContextMenu';
let menu = document.getElementById("fileContextMenu");
if (!menu) { function qMenu() { return document.getElementById(MENU_ID); }
menu = document.createElement("div"); function setText(btn, key) { btn.querySelector('span').textContent = t(key); }
menu.id = "fileContextMenu";
menu.style.position = "fixed"; // One-time: localize labels
menu.style.backgroundColor = "#fff"; function localizeMenu() {
menu.style.border = "1px solid #ccc"; const m = qMenu(); if (!m) return;
menu.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.2)"; const map = {
menu.style.zIndex = "9999"; 'create_file': 'create_file',
menu.style.padding = "5px 0"; 'delete_selected': 'delete_selected',
menu.style.minWidth = "150px"; 'copy_selected': 'copy_selected',
document.body.appendChild(menu); 'move_selected': 'move_selected',
} 'download_zip': 'download_zip',
menu.innerHTML = ""; 'extract_zip': 'extract_zip',
menuItems.forEach(item => { 'tag_selected': 'tag_selected',
let menuItem = document.createElement("div"); 'preview': 'preview',
menuItem.textContent = item.label; 'edit': 'edit',
menuItem.style.padding = "5px 15px"; 'rename': 'rename',
menuItem.style.cursor = "pointer"; 'tag_file': 'tag_file'
menuItem.addEventListener("mouseover", () => { };
menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0"; Object.entries(map).forEach(([action, key]) => {
}); const el = m.querySelector(`.mi[data-action="${action}"]`);
menuItem.addEventListener("mouseout", () => { if (el) setText(el, key);
menuItem.style.backgroundColor = "";
});
menuItem.addEventListener("click", () => {
item.action();
hideFileContextMenu();
});
menu.appendChild(menuItem);
}); });
}
menu.style.left = x + "px";
menu.style.top = y + "px"; // Show/hide items based on selection state
menu.style.display = "block"; function configureVisibility({ any, one, many, anyZip, canEdit }) {
const m = qMenu(); if (!m) return;
const menuRect = menu.getBoundingClientRect();
const viewportHeight = window.innerHeight; const show = (sel, on) => sel.forEach(el => el.hidden = !on);
if (menuRect.bottom > viewportHeight) {
let newTop = viewportHeight - menuRect.height; show(m.querySelectorAll('[data-when="always"]'), true);
if (newTop < 0) newTop = 0; show(m.querySelectorAll('[data-when="any"]'), any);
menu.style.top = newTop + "px"; 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() { export function hideFileContextMenu() {
const menu = document.getElementById("fileContextMenu"); const m = qMenu();
if (menu) { if (m) m.hidden = true;
menu.style.display = "none"; }
}
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) { export function fileListContextMenuHandler(e) {
e.preventDefault(); e.preventDefault();
let row = e.target.closest("tr"); // Check row if needed
const row = e.target.closest('tr');
if (row) { if (row) {
const checkbox = row.querySelector(".file-checkbox"); const cb = row.querySelector('.file-checkbox');
if (checkbox && !checkbox.checked) { if (cb && !cb.checked) {
checkbox.checked = true; cb.checked = true;
updateRowHighlight(checkbox); updateRowHighlight(cb);
} }
} }
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value); const state = currentSelection();
configureVisibility(state);
let menuItems = [ placeMenu(e.clientX, e.clientY);
{ label: t("create_file"), action: () => openCreateFileModal() },
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } }, // Stash for click handlers
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } }, window.__filr_ctx_state = state;
{ 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";
const folderPath = folder === "root"
? "uploads/"
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
previewFile(folderPath + encodeURIComponent(file.name) + "?t=" + new Date().getTime(), 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);
} }
// --- 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() { export function bindFileListContextMenu() {
const fileListContainer = document.getElementById("fileList"); const container = document.getElementById('fileList');
if (fileListContainer) { const menu = qMenu();
fileListContainer.oncontextmenu = fileListContextMenuHandler; 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) { // Rebind after table render (keeps your original behavior)
const menu = document.getElementById("fileContextMenu"); (function () {
if (menu && menu.style.display === "block") { const orig = window.renderFileTable;
hideFileContextMenu(); 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 });
} }
});
// Rebind context menu after file table render.
(function() {
const originalRenderFileTable = window.renderFileTable;
window.renderFileTable = function(folder) {
originalRenderFileTable(folder);
bindFileListContextMenu();
};
})(); })();

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
// js/folderShareModal.js // js/folderShareModal.js
import { escapeHTML, showToast } from './domUtils.js'; import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js'; import { t } from './i18n.js?v={{APP_QVER}}';
export function openFolderShareModal(folder) { export function openFolderShareModal(folder) {
// Remove any existing modal // Remove any existing modal

View File

@@ -233,7 +233,7 @@ const translations = {
"error_generating_recovery_code": "Error generating recovery code", "error_generating_recovery_code": "Error generating recovery code",
"error_loading_qr_code": "Error loading QR code.", "error_loading_qr_code": "Error loading QR code.",
"error_disabling_totp_setting": "Error disabling TOTP setting", "error_disabling_totp_setting": "Error disabling TOTP setting",
"user_management": "User Management", "user_management": "Users, Groups & Access",
"add_user": "Add User", "add_user": "Add User",
"remove_user": "Remove User", "remove_user": "Remove User",
"user_permissions": "User Permissions", "user_permissions": "User Permissions",
@@ -247,7 +247,7 @@ const translations = {
"login_options": "Login Options", "login_options": "Login Options",
"disable_login_form": "Disable Login Form", "disable_login_form": "Disable Login Form",
"disable_basic_http_auth": "Disable Basic HTTP Auth", "disable_basic_http_auth": "Disable Basic HTTP Auth",
"disable_oidc_login": "Disable OIDC Login", "disable_oidc_login": "Disable OIDC Login (OIDC Config Required to enable)",
"save_settings": "Save Settings", "save_settings": "Save Settings",
"at_least_one_login_method": "At least one login method must remain enabled.", "at_least_one_login_method": "At least one login method must remain enabled.",
"settings_updated_successfully": "Settings updated successfully.", "settings_updated_successfully": "Settings updated successfully.",
@@ -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.", "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_folder": "Move Folder...",
"context_move_here": "Move Here", "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: { es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.", "please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

View File

@@ -1,9 +1,9 @@
// trashRestoreDelete.js // trashRestoreDelete.js
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
import { toggleVisibility, showToast } from './domUtils.js'; import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js'; import { loadFolderTree, refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js'; import { t } from './i18n.js?v={{APP_QVER}}';
function showConfirm(message, onConfirm) { function showConfirm(message, onConfirm) {
const modal = document.getElementById("customConfirmModal"); const modal = document.getElementById("customConfirmModal");
@@ -89,6 +89,7 @@ export function setupTrashRestoreDelete() {
toggleVisibility("restoreFilesModal", false); toggleVisibility("restoreFilesModal", false);
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder); loadFolderTree(window.currentFolder);
refreshFolderIcon(window.currentFolder);
}) })
.catch(err => { .catch(err => {
console.error("Error restoring files:", err); console.error("Error restoring files:", err);

View File

@@ -1,9 +1,180 @@
import { initFileActions } from './fileActions.js'; import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
import { displayFilePreview } from './filePreview.js'; import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
import { showToast, escapeHTML } from './domUtils.js'; import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js'; import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { t } from './i18n.js'; 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) Helpers for DragandDrop Folder Uploads (Original Code)
@@ -36,6 +207,38 @@ function traverseFileTreePromise(item, path = "") {
}); });
} }
// --- Lazy loader for Resumable.js (no CSP inline, cached, safe) ---
const RESUMABLE_SRC = '/vendor/resumable/1.1.0/resumable.min.js?v={{APP_QVER}}';
let _resumableLoadPromise = null;
function loadScriptOnce(src) {
if (loadScriptOnce._cache?.has(src)) return loadScriptOnce._cache.get(src);
loadScriptOnce._cache = loadScriptOnce._cache || new Map();
const p = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.async = true;
s.onload = resolve;
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
loadScriptOnce._cache.set(src, p);
return p;
}
function lazyLoadResumable() {
if (window.Resumable) return Promise.resolve(window.Resumable);
if (!_resumableLoadPromise) {
_resumableLoadPromise = loadScriptOnce(RESUMABLE_SRC).then(() => window.Resumable);
}
return _resumableLoadPromise;
}
// Optional: let main.js prefetch it in the background
export function warmUpResumable() {
lazyLoadResumable().catch(() => {/* ignore warm-up failure */});
}
// Recursively retrieve files from DataTransfer items. // Recursively retrieve files from DataTransfer items.
function getFilesFromDataTransferItems(items) { function getFilesFromDataTransferItems(items) {
const promises = []; const promises = [];
@@ -401,36 +604,49 @@ function processFiles(filesInput) {
Resumable.js Integration for File Picker Uploads Resumable.js Integration for File Picker Uploads
(Only files chosen via file input use Resumable; folder uploads use original code.) (Only files chosen via file input use Resumable; folder uploads use original code.)
----------------------------------------------------- */ ----------------------------------------------------- */
const useResumable = true; // Enable resumable for file picker uploads const useResumable = true;
let resumableInstance; let resumableInstance = null;
function initResumableUpload() { let _pendingPickedFiles = []; // files picked before library/instance ready
resumableInstance = new Resumable({ let _resumableReady = false;
target: "/api/upload/upload.php",
chunkSize: 1.5 * 1024 * 1024, // Make init async-safe; it resolves when Resumable is constructed
simultaneousUploads: 3, async function initResumableUpload() {
forceChunkSize: true, if (resumableInstance) return;
testChunks: false, // Load the library if needed
withCredentials: true, const ResumableCtor = await lazyLoadResumable().catch(err => {
headers: { 'X-CSRF-Token': window.csrfToken }, console.error('Failed to load Resumable.js:', err);
query: () => ({ return null;
folder: window.currentFolder || "root",
upload_token: window.csrfToken
})
}); });
if (!ResumableCtor) return;
// Construct the instance once
if (!resumableInstance) {
resumableInstance = new ResumableCtor({
target: "/api/upload/upload.php",
chunkSize: 1.5 * 1024 * 1024,
simultaneousUploads: 3,
forceChunkSize: true,
testChunks: true,
withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken },
query: () => ({
folder: window.currentFolder || "root",
upload_token: window.csrfToken
})
});
}
// keep query fresh when folder changes (call this from your folder nav code) // keep query fresh when folder changes (call this from your folder nav code)
function updateResumableQuery() { function updateResumableQuery() {
if (!resumableInstance) return; if (!resumableInstance) return;
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken; resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
// if you're not using a function for query, do:
resumableInstance.opts.query.folder = window.currentFolder || 'root'; resumableInstance.opts.query.folder = window.currentFolder || 'root';
resumableInstance.opts.query.upload_token = window.csrfToken; resumableInstance.opts.query.upload_token = window.csrfToken;
} }
const fileInput = document.getElementById("file"); const fileInput = document.getElementById("file");
if (fileInput) { if (fileInput) {
// Assign Resumable to file input for file picker uploads.
resumableInstance.assignBrowse(fileInput);
fileInput.addEventListener("change", function () { fileInput.addEventListener("change", function () {
for (let i = 0; i < fileInput.files.length; i++) { for (let i = 0; i < fileInput.files.length; i++) {
resumableInstance.addFile(fileInput.files[i]); resumableInstance.addFile(fileInput.files[i]);
@@ -447,6 +663,11 @@ function initResumableUpload() {
window.selectedFiles = []; window.selectedFiles = [];
} }
window.selectedFiles.push(file); window.selectedFiles.push(file);
// Track as in-progress draft at 0%
upsertResumableDraft(file, 0);
showResumableDraftBanner();
const progressContainer = document.getElementById("uploadProgressContainer"); const progressContainer = document.getElementById("uploadProgressContainer");
// Check if a wrapper already exists; if not, create one with a UL inside. // Check if a wrapper already exists; if not, create one with a UL inside.
@@ -474,8 +695,40 @@ function initResumableUpload() {
resumableInstance.on("fileProgress", function (file) { resumableInstance.on("fileProgress", function (file) {
const progress = file.progress(); // value between 0 and 1 const progress = file.progress(); // value between 0 and 1
const percent = Math.floor(progress * 100); let percent = Math.floor(progress * 100);
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
// 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 (li && li.progressBar) {
if (percent < 99) { if (percent < 99) {
li.progressBar.style.width = percent + "%"; li.progressBar.style.width = percent + "%";
@@ -507,6 +760,7 @@ function initResumableUpload() {
pauseResumeBtn.disabled = false; pauseResumeBtn.disabled = false;
} }
} }
upsertResumableDraft(file, percent);
}); });
resumableInstance.on("fileSuccess", function (file, message) { resumableInstance.on("fileSuccess", function (file, message) {
@@ -543,8 +797,11 @@ function initResumableUpload() {
if (removeBtn) removeBtn.style.display = "none"; if (removeBtn) removeBtn.style.display = "none";
setTimeout(() => li.remove(), 5000); setTimeout(() => li.remove(), 5000);
} }
refreshFolderIcon(window.currentFolder);
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
// This file finished successfully, remove its draft record
clearResumableDraft(file.uniqueIdentifier);
showResumableDraftBanner();
}); });
@@ -562,18 +819,22 @@ function initResumableUpload() {
pauseResumeBtn.disabled = false; pauseResumeBtn.disabled = false;
} }
showToast("Error uploading file: " + file.fileName); 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. // 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) { if (!hasError) {
// All files succeeded—clear the file input and progress container after 5 seconds. // All files succeeded—clear the file input and progress container after 5 seconds.
setTimeout(() => { setTimeout(() => {
const fileInput = document.getElementById("file"); const fileInput = document.getElementById("file");
if (fileInput) fileInput.value = ""; if (fileInput) fileInput.value = "";
const progressContainer = document.getElementById("uploadProgressContainer"); const progressContainer = document.getElementById("uploadProgressContainer");
progressContainer.innerHTML = ""; if (progressContainer) {
progressContainer.innerHTML = "";
}
window.selectedFiles = []; window.selectedFiles = [];
adjustFolderHelpExpansionClosed(); adjustFolderHelpExpansionClosed();
const fileInfoContainer = document.getElementById("fileInfoContainer"); const fileInfoContainer = document.getElementById("fileInfoContainer");
@@ -582,23 +843,66 @@ function initResumableUpload() {
} }
const dropArea = document.getElementById("uploadDropArea"); const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault(); 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); }, 5000);
} else { } else {
showToast("Some files failed to upload. Please check the list."); showToast("Some files failed to upload. Please check the list.");
} }
}); });
_resumableReady = true;
if (_pendingPickedFiles.length) {
updateResumableQuery();
for (const f of _pendingPickedFiles) resumableInstance.addFile(f);
_pendingPickedFiles = [];
}
} }
/* ----------------------------------------------------- /* -----------------------------------------------------
XHR-based submitFiles for DragandDrop (Folder) Uploads XHR-based submitFiles for DragandDrop (Folder) Uploads
----------------------------------------------------- */ ----------------------------------------------------- */
function submitFiles(allFiles) { function submitFiles(allFiles) {
const folderToUse = window.currentFolder || "root"; const folderToUse = (() => {
const f = window.currentFolder || "root";
try { return decodeURIComponent(f); } catch { return f; }
})();
const progressContainer = document.getElementById("uploadProgressContainer"); const progressContainer = document.getElementById("uploadProgressContainer");
const fileInput = document.getElementById("file"); 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 progressElements = {};
const listItems = progressContainer.querySelectorAll("li.upload-progress-item");
listItems.forEach(item => { listItems.forEach(item => {
progressElements[item.dataset.uploadIndex] = item; progressElements[item.dataset.uploadIndex] = item;
}); });
@@ -624,7 +928,7 @@ function submitFiles(allFiles) {
if (e.lengthComputable) { if (e.lengthComputable) {
currentPercent = Math.round((e.loaded / e.total) * 100); currentPercent = Math.round((e.loaded / e.total) * 100);
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
if (li) { if (li && li.progressBar) {
const elapsed = (Date.now() - li.startTime) / 1000; const elapsed = (Date.now() - li.startTime) / 1000;
let speed = ""; let speed = "";
if (elapsed > 0) { if (elapsed > 0) {
@@ -660,12 +964,12 @@ function submitFiles(allFiles) {
return; // skip the "finishedCount++" and error/success logic for now return; // skip the "finishedCount++" and error/success logic for now
} }
// ─── Normal success/error handling ──────────────────────────── // ─── Normal success/error handling ────────────────────────────
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) { if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
// real success // real success
if (li) { if (li && li.progressBar) {
li.progressBar.style.width = "100%"; li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done"; li.progressBar.innerText = "Done";
if (li.removeBtn) li.removeBtn.style.display = "none"; if (li.removeBtn) li.removeBtn.style.display = "none";
@@ -674,39 +978,40 @@ function submitFiles(allFiles) {
} else { } else {
// real failure // real failure
if (li) { if (li && li.progressBar) {
li.progressBar.innerText = "Error"; li.progressBar.innerText = "Error";
} }
allSucceeded = false; allSucceeded = false;
} }
if (file.isClipboard) { if (file.isClipboard) {
setTimeout(() => { setTimeout(() => {
window.selectedFiles = []; window.selectedFiles = [];
updateFileInfoCount(); updateFileInfoCount();
const progressContainer = document.getElementById("uploadProgressContainer"); const pc = document.getElementById("uploadProgressContainer");
if (progressContainer) progressContainer.innerHTML = ""; if (pc) pc.innerHTML = "";
const fileInfoContainer = document.getElementById("fileInfoContainer"); const fic = document.getElementById("fileInfoContainer");
if (fileInfoContainer) { if (fic) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`; fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
} }
}, 5000); }, 5000);
} }
// ─── Only now count this chunk as finished ─────────────────── // ─── Only now count this upload as finished ───────────────────
finishedCount++; finishedCount++;
if (finishedCount === allFiles.length) { if (finishedCount === allFiles.length) {
const succeededCount = uploadResults.filter(Boolean).length; const succeededCount = uploadResults.filter(Boolean).length;
const failedCount = allFiles.length - succeededCount; const failedCount = allFiles.length - succeededCount;
setTimeout(() => { setTimeout(() => {
refreshFileList(allFiles, uploadResults, progressElements); refreshFileList(allFiles, uploadResults, progressElements);
}, 250); }, 250);
} }
}); });
xhr.addEventListener("error", function () { xhr.addEventListener("error", function () {
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
if (li) { if (li && li.progressBar) {
li.progressBar.innerText = "Error"; li.progressBar.innerText = "Error";
} }
uploadResults[file.uploadIndex] = false; uploadResults[file.uploadIndex] = false;
@@ -722,7 +1027,7 @@ if (finishedCount === allFiles.length) {
xhr.addEventListener("abort", function () { xhr.addEventListener("abort", function () {
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
if (li) { if (li && li.progressBar) {
li.progressBar.innerText = "Aborted"; li.progressBar.innerText = "Aborted";
} }
uploadResults[file.uploadIndex] = false; uploadResults[file.uploadIndex] = false;
@@ -752,38 +1057,42 @@ if (finishedCount === allFiles.length) {
}) })
.map(s => s.trim().toLowerCase()) .map(s => s.trim().toLowerCase())
.filter(Boolean); .filter(Boolean);
let overallSuccess = true; let overallSuccess = true;
let succeeded = 0; let succeeded = 0;
allFiles.forEach(file => { allFiles.forEach(file => {
const clientFileName = file.name.trim().toLowerCase(); const clientFileName = file.name.trim().toLowerCase();
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath); 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"; li.progressBar.innerText = "Error";
} }
overallSuccess = false; overallSuccess = false;
} else if (li) { } else if (li) {
succeeded++; succeeded++;
// Schedule removal of successful file entry after 5 seconds. // Schedule removal of successful file entry after 5 seconds.
setTimeout(() => { setTimeout(() => {
li.remove(); li.remove();
delete progressElements[file.uploadIndex]; delete progressElements[file.uploadIndex];
updateFileInfoCount(); updateFileInfoCount();
const progressContainer = document.getElementById("uploadProgressContainer"); const pc = document.getElementById("uploadProgressContainer");
if (progressContainer && progressContainer.querySelectorAll("li.upload-progress-item").length === 0) { if (pc && pc.querySelectorAll("li.upload-progress-item").length === 0) {
const fileInput = document.getElementById("file"); const fi = document.getElementById("file");
if (fileInput) fileInput.value = ""; if (fi) fi.value = "";
progressContainer.innerHTML = ""; pc.innerHTML = "";
adjustFolderHelpExpansionClosed(); adjustFolderHelpExpansionClosed();
const fileInfoContainer = document.getElementById("fileInfoContainer"); const fic = document.getElementById("fileInfoContainer");
if (fileInfoContainer) { if (fic) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`; fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
} }
const dropArea = document.getElementById("uploadDropArea"); const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault(); if (dropArea) setDropAreaDefault();
window.selectedFiles = [];
} }
}, 5000); }, 5000);
} }
@@ -793,7 +1102,7 @@ if (finishedCount === allFiles.length) {
const failed = allFiles.length - succeeded; const failed = allFiles.length - succeeded;
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`); showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
} else { } else {
showToast(`${succeeded} file succeeded. Please check the list.`); showToast(`${succeeded} file(s) succeeded. Please check the list.`);
} }
}) })
.catch(error => { .catch(error => {
@@ -802,7 +1111,6 @@ if (finishedCount === allFiles.length) {
}) })
.finally(() => { .finally(() => {
loadFolderTree(window.currentFolder); loadFolderTree(window.currentFolder);
}); });
} }
} }
@@ -839,7 +1147,8 @@ function initUpload() {
dropArea.addEventListener("drop", function (e) { dropArea.addEventListener("drop", function (e) {
e.preventDefault(); e.preventDefault();
dropArea.style.backgroundColor = ""; dropArea.style.backgroundColor = "";
const dt = e.dataTransfer; const dt = e.dataTransfer || window.__pendingDropData || null;
window.__pendingDropData = null;
if (dt.items && dt.items.length > 0) { if (dt.items && dt.items.length > 0) {
getFilesFromDataTransferItems(dt.items).then(files => { getFilesFromDataTransferItems(dt.items).then(files => {
if (files.length > 0) { if (files.length > 0) {
@@ -857,33 +1166,68 @@ function initUpload() {
} }
if (fileInput) { if (fileInput) {
fileInput.addEventListener("change", function () { fileInput.addEventListener("change", async function () {
const files = Array.from(fileInput.files || []);
if (!files.length) return;
if (useResumable) { if (useResumable) {
// For file picker, if resumable is enabled, let it handle the files. // New resumable batch: reset selectedFiles so the count is correct
for (let i = 0; i < fileInput.files.length; i++) { window.selectedFiles = [];
resumableInstance.addFile(fileInput.files[i]);
// Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
for (const f of files) {
resumableInstance.addFile(f);
}
} else {
// If Resumable failed to load, fall back to XHR
processFiles(files);
} }
} else { } else {
processFiles(fileInput.files); // Non-resumable: normal XHR path, drag-and-drop etc.
processFiles(files);
} }
}); });
} }
if (uploadForm) { if (uploadForm) {
uploadForm.addEventListener("submit", function (e) { uploadForm.addEventListener("submit", async function (e) {
e.preventDefault(); e.preventDefault();
const files = window.selectedFiles || (fileInput ? fileInput.files : []);
if (!files || files.length === 0) { const files =
(Array.isArray(window.selectedFiles) && window.selectedFiles.length)
? window.selectedFiles
: (fileInput ? Array.from(fileInput.files || []) : []);
if (!files || !files.length) {
showToast("No files selected."); showToast("No files selected.");
return; return;
} }
// If files come from file picker (no relative path), use Resumable.
if (useResumable && (!files[0].customRelativePath || files[0].customRelativePath === "")) { // If we have any files queued in Resumable, treat this as a resumable upload.
// Ensure current folder is updated. const hasResumableFiles =
resumableInstance.opts.query.folder = window.currentFolder || "root"; useResumable &&
resumableInstance.upload(); resumableInstance &&
showToast("Resumable upload started..."); Array.isArray(resumableInstance.files) &&
resumableInstance.files.length > 0;
if (hasResumableFiles) {
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
// 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 {
// Hard fallback should basically never happen
submitFiles(files);
}
} else { } else {
// No resumable queue → drag-and-drop / paste / simple input → XHR path
submitFiles(files); submitFiles(files);
} }
}); });
@@ -892,6 +1236,7 @@ function initUpload() {
if (useResumable) { if (useResumable) {
initResumableUpload(); initResumableUpload();
} }
showResumableDraftBanner();
} }
export { initUpload }; export { initUpload };

View File

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

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
}

21
public/vendor/redoc/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present, Rebilly, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1832
public/vendor/redoc/redoc.standalone.js vendored Normal file

File diff suppressed because one or more lines are too long

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."

54
scripts/stamp-assets.sh Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# usage: scripts/stamp-assets.sh vX.Y.Z /path/to/target/dir
set -euo pipefail
VER="${1:?usage: stamp-assets.sh vX.Y.Z target_dir}"
QVER="${VER#v}"
TARGET="${2:-.}"
echo "Stamping assets in: $TARGET"
echo "VER=${VER} QVER=${QVER}"
cd "$TARGET"
# Normalize CRLF to LF (if any files were edited on Windows)
# We only touch web assets.
find public \( -name '*.html' -o -name '*.php' -o -name '*.css' -o -name '*.js' \) -type f -print0 \
| xargs -0 -r sed -i 's/\r$//'
# --- HTML/CSS/PHP: stamp ?v=... and {{APP_VER}} ---
# (?v=...) -> ?v=<QVER>
HTML_CSS_COUNT=0
while IFS= read -r -d '' f; do
sed -E -i "s/(\?v=)[^\"'&<>\s]*/\1${QVER}/g" "$f"
sed -E -i "s/\{\{APP_VER\}\}/${VER}/g" "$f"
HTML_CSS_COUNT=$((HTML_CSS_COUNT+1))
done < <(find public -type f \( -name '*.html' -o -name '*.php' -o -name '*.css' \) -print0)
# --- JS: stamp placeholders and normalize any pre-existing ?v=... ---
JS_COUNT=0
while IFS= read -r -d '' f; do
# Replace placeholders
sed -E -i "s/\{\{APP_VER\}\}/${VER}/g" "$f"
sed -E -i "s/\{\{APP_QVER\}\}/${QVER}/g" "$f"
# Normalize any "?v=..." that appear in ESM imports or strings
# This keeps any ".js" or ".mjs" then forces ?v=<QVER>
perl -0777 -i -pe "s@(\.m?js)\?v=[^\"')]+@\1?v=${QVER}@g" "$f"
JS_COUNT=$((JS_COUNT+1))
done < <(find public -type f -name '*.js' -print0)
# Force-write version.js (source of truth in stamped output)
if [[ -f public/js/version.js ]]; then
printf "window.APP_VERSION = '%s';\n" "$VER" > public/js/version.js
fi
echo "Touched files: HTML/CSS/PHP=${HTML_CSS_COUNT}, JS=${JS_COUNT}"
# Final self-check: fail if anything is left
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" public \
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
echo "ERROR: Placeholders remain after stamping." >&2
exit 2
fi
echo "✅ Stamped to ${VER} (${QVER})"

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

Some files were not shown because too many files have changed in this diff Show More