Compare commits

...

100 Commits

Author SHA1 Message Date
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
Ryan
3351a11927 ci(release): touch version.js to trigger release-on-version workflow 2025-10-27 05:40:36 -04:00
Ryan
4dddcf0f99 chore(ci): remove CodeQL workflow 2025-10-27 05:37:45 -04:00
Ryan
35966964e7 chore(ci,codeql): lint fixes, release trigger; stamp ?v in HTML/CSS; fix editor cache-busting 2025-10-27 05:31:01 -04:00
github-actions[bot]
7fe8e858ae chore(release): set APP_VERSION and stamp assets to v1.6.9 [skip ci] 2025-10-27 08:48:46 +00:00
Ryan
64332211c9 release(v1.6.9): feat(core) localize assets, harden headers, and speed up load 2025-10-27 04:48:31 -04:00
Ryan
3e37738e3f ci(release): touch version.js to trigger release-on-version workflow 2025-10-25 20:57:43 -04:00
Ryan
2ba33f40f8 ci(release): add workflow to auto-publish GitHub Release on version.js change 2025-10-25 20:54:49 -04:00
Ryan
badcf5c02b ci(release): add workflow to auto-publish GitHub Release on version.js updates 2025-10-25 20:51:58 -04:00
github-actions[bot]
89976f444f chore: set APP_VERSION to v1.6.8 2025-10-26 00:33:15 +00:00
Ryan
9c53c37f38 release(v1.6.8): fix(ui) prevent Extract/Create flash on refresh; remember last folder 2025-10-25 20:33:01 -04:00
Ryan
a400163dfb docs(assets): folder access screenshot 2025-10-25 15:08:46 -04:00
Ryan
ebe5939bf5 docs(assets,readme): refresh screenshots and demo video for v1.6.7 2025-10-25 14:26:36 -04:00
Ryan
83757c7470 new video demo date and link in README
Updated the video demo date and link in README.
2025-10-25 13:58:25 -04:00
github-actions[bot]
8e363ea758 chore: set APP_VERSION to v1.6.7 2025-10-25 06:16:13 +00:00
Ryan
2739925f0b release(v1.6.7): Folder Move feature, stable DnD persistence, safer uploads, and ACL/UI polish 2025-10-25 02:16:01 -04:00
github-actions[bot]
b5610cf156 chore: set APP_VERSION to v1.6.6 2025-10-24 07:21:53 +00:00
Ryan
ae932a9aa9 release(v1.6.6): header-mounted toggle, dark-mode polish, persistent layout, and ACL fix 2025-10-24 03:21:39 -04:00
github-actions[bot]
a106d47f77 chore: set APP_VERSION to v1.6.5 2025-10-24 06:12:09 +00:00
Ryan
41d464a4b3 release(v1.6.5): fix PHP warning and upload-flag check in capabilities.php 2025-10-24 02:11:55 -04:00
Ryan
9e69f19e23 chore: add YAML document start markers and fix lint warnings 2025-10-24 01:51:18 -04:00
Ryan
1df7bc3f87 ci(sync-changelog): fix YAML lint error by trimming trailing spaces and ensuring EOF newline 2025-10-24 01:46:58 -04:00
github-actions[bot]
e5f9831d73 chore: set APP_VERSION to v1.6.4 2025-10-24 05:36:43 +00:00
Ryan
553bc84404 release(v1.6.4): runtime version injection + CI bump/sync; caching tweaks 2025-10-24 01:36:30 -04:00
Ryan
88a8857a6f release(v1.6.3): drag/drop card persistence, admin UX fixes, and docs (closes #58) 2025-10-24 00:22:22 -04:00
Ryan
edefaaca36 docs(README): add additional badges 2025-10-23 03:36:27 -04:00
Ryan
ef0a8da696 chore(funding): enable GitHub Sponsors and Ko-fi links 2025-10-23 03:16:27 -04:00
142 changed files with 17724 additions and 6752 deletions

44
.gitattributes vendored
View File

@@ -1,4 +1,40 @@
public/api.html linguist-documentation # --- Docs that shouldn't count toward code stats
public/openapi.json linguist-documentation public/api.php linguist-documentation
resources/ export-ignore public/openapi.json linguist-documentation
.github/ export-ignore openapi.json.dist linguist-documentation
SECURITY.md linguist-documentation
CHANGELOG.md linguist-documentation
CONTRIBUTING.md linguist-documentation
CODE_OF_CONDUCT.md linguist-documentation
LICENSE linguist-documentation
README.md linguist-documentation
# --- Vendored/minified stuff: exclude from Linguist
public/vendor/** linguist-vendored
public/css/vendor/** linguist-vendored
public/fonts/** linguist-vendored
public/js/**/*.min.js linguist-vendored
public/**/*.min.css linguist-vendored
public/**/*.map linguist-generated
# --- Treat assets as binary (nicer diffs)
*.png -diff
*.jpg -diff
*.jpeg -diff
*.gif -diff
*.webp -diff
*.svg -diff
*.ico -diff
*.woff -diff
*.woff2 -diff
*.ttf -diff
*.otf -diff
*.zip -diff
# --- Keep these out of auto-generated source archives (OK to ignore)
# Only ignore things you *never* need in release tarballs
.github/ export-ignore
resources/ export-ignore
# --- Normalize text files
* text=auto

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
---
github: [error311]
ko_fi: error311

271
.github/workflows/release-on-version.yml vendored Normal file
View File

@@ -0,0 +1,271 @@
---
name: Release on version.js update
on:
push:
branches: ["master"]
paths:
- public/js/version.js
workflow_dispatch:
inputs:
ref:
description: "Ref (branch/sha) to build from (default: master)"
required: false
version:
description: "Explicit version tag to release (e.g., v1.8.12). If empty, parse from public/js/version.js."
required: false
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
if: |
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch'
concurrency:
group: release-${{ github.event_name }}-${{ github.run_id }}
cancel-in-progress: false
steps:
- name: Resolve source ref
id: pickref
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
if [[ -n "${{ github.event.inputs.ref }}" ]]; then
REF_IN="${{ github.event.inputs.ref }}"
else
REF_IN="master"
fi
if git ls-remote --exit-code --heads https://github.com/${{ github.repository }}.git "$REF_IN" >/dev/null 2>&1; then
REF="$REF_IN"
else
REF="$REF_IN"
fi
else
REF="${{ github.sha }}"
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
echo "Using ref=$REF"
- name: Checkout chosen ref (full history + tags, no persisted token)
uses: actions/checkout@v4
with:
ref: ${{ steps.pickref.outputs.ref }}
fetch-depth: 0
persist-credentials: false
- name: Determine version
id: ver
shell: bash
run: |
set -euo pipefail
if [[ -n "${{ github.event.inputs.version || '' }}" ]]; then
VER="${{ github.event.inputs.version }}"
else
if [[ ! -f public/js/version.js ]]; then
echo "public/js/version.js not found; cannot auto-detect version." >&2
exit 1
fi
VER="$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")"
if [[ -z "$VER" ]]; then
echo "Could not parse APP_VERSION from public/js/version.js" >&2
exit 1
fi
fi
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "Detected version: $VER"
- name: Skip if tag already exists
id: tagcheck
shell: bash
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/${{ steps.ver.outputs.version }}" >/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${{ steps.ver.outputs.version }} already exists. Skipping release."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- 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'
id: notes
shell: bash
run: |
set -euo pipefail
NOTES_PATH=""
if [[ -f CHANGELOG.md ]]; then
awk '
BEGIN{found=0}
/^## / && !found {found=1}
found && /^---$/ {exit}
found {print}
' CHANGELOG.md > CHANGELOG_SNIPPET.md || true
sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' CHANGELOG_SNIPPET.md || true
if [[ -s CHANGELOG_SNIPPET.md ]]; then
NOTES_PATH="CHANGELOG_SNIPPET.md"
fi
fi
echo "path=$NOTES_PATH" >> "$GITHUB_OUTPUT"
- 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'
shell: bash
run: |
set -euo pipefail
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
- name: Create GitHub Release
if: steps.tagcheck.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.version }}
target_commitish: ${{ steps.pickref.outputs.ref }}
name: ${{ steps.ver.outputs.version }}
body_path: RELEASE_BODY.md
generate_release_notes: false
files: |
FileRise-${{ steps.ver.outputs.version }}.zip
FileRise-${{ steps.ver.outputs.version }}.zip.sha256

View File

@@ -1,44 +1,115 @@
--- ---
name: Sync Changelog to Docker Repo name: Bump version and sync Changelog to Docker Repo
on: 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:
sync: bump_and_sync:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout FileRise - name: Checkout FileRise
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
path: file-rise fetch-depth: 0
ref: ${{ github.ref }}
- name: Extract version from commit message
id: ver
shell: bash
run: |
set -euo pipefail
MSG="${{ github.event.head_commit.message }}"
if [[ "$MSG" =~ release\((v[0-9]+\.[0-9]+\.[0-9]+)\) ]]; then
echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
echo "Found version: ${BASH_REMATCH[1]}"
else
echo "version=" >> "$GITHUB_OUTPUT"
echo "No release(vX.Y.Z) tag in commit message; skipping bump."
fi
# Ensure we're on the branch and up to date BEFORE modifying files
- name: Ensure clean branch (no local mods), update from remote
if: steps.ver.outputs.version != ''
shell: bash
run: |
set -euo pipefail
# Be on a named branch that tracks the remote
git checkout -B "${{ github.ref_name }}" --track "origin/${{ github.ref_name }}" || git checkout -B "${{ github.ref_name }}"
# Make sure the worktree is clean
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "::error::Working tree not clean before update. Aborting."
git status --porcelain
exit 1
fi
# Update branch
git pull --rebase origin "${{ github.ref_name }}"
- name: Update public/js/version.js (source of truth)
if: steps.ver.outputs.version != ''
shell: bash
run: |
set -euo pipefail
cat > public/js/version.js <<'EOF'
// generated by CI
window.APP_VERSION = '${{ steps.ver.outputs.version }}';
EOF
- name: Commit version.js only
if: steps.ver.outputs.version != ''
shell: bash
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add public/js/version.js
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "chore(release): set APP_VERSION to ${{ steps.ver.outputs.version }} [skip ci]"
git push origin "${{ github.ref_name }}"
fi
- name: Checkout filerise-docker - name: Checkout filerise-docker
if: steps.ver.outputs.version != ''
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
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 - name: Copy CHANGELOG.md and write VERSION
if: steps.ver.outputs.version != ''
shell: bash
run: | run: |
cp file-rise/CHANGELOG.md docker-repo/CHANGELOG.md set -euo pipefail
cp CHANGELOG.md docker-repo/CHANGELOG.md
echo "${{ steps.ver.outputs.version }}" > docker-repo/VERSION
- name: Commit & push - name: Commit & push to docker repo
if: steps.ver.outputs.version != ''
working-directory: docker-repo working-directory: docker-repo
shell: bash
run: | run: |
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 CHANGELOG.md git add CHANGELOG.md VERSION
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 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

View File

@@ -1,5 +1,905 @@
# Changelog # Changelog
## Changes 11/11/2025 (v1.9.3)
release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release
- UI / Icons
- Replace Material icon in folder strip with shared `folderSVG()` and export it for reuse. Adds clipPaths, subtle gradients, and `shape-rendering: geometricPrecision` to eliminate the tiny seam.
- Add ruled “paper” lines and blue handwriting dashes; CSS for `.paper-line` and `.paper-ink` included.
- Match strokes between tree (24px) and strip (48px) so both look identical; round joins/caps to avoid nicks.
- Polish folder strip layout & hover: tighter spacing, centered icon+label, improved wrapping.
- Folder color & non-empty detection
- Live color sync: after saving a color we dispatch `folderColorChanged`; strip repaints and tree refreshes.
- Async strip icon: paint immediately, then flip to “paper” if the folder has contents. HSL helpers compute front/back/stroke shades.
- FileList strip
- Render subfolders with `<span class="folder-svg">` + name, wire context menu actions (move, color, share, etc.), and attach icons for each tile.
- Exports & helpers
- Export `openColorFolderModal(...)` and `openMoveFolderUI(...)` for the strip and toolbar; use `refreshFolderIcon(...)` after ops to keep icons current.
- AppCore
- Update file upload DnD relay hook to `#fileList` (id rename).
- CSS tweaks
- Bring tree icon stroke/paint rules in line with the strip, add scribble styles, and adjust margins/spacing.
- CI/CD (release)
- Build PHP dependencies during release: setup PHP 8.3 + Composer, cache downloads, install into `staging/vendor/`, exclude `vendor/` from placeholder checks, and ship artifact including `vendor/`.
- Changelog highlights
- Sharper, seam-free folder SVGs shared across tree & strip, with paper lines + handwriting accents.
- Real-time folder color propagation between views.
- Folder strip switched to SVG tiles with better layout + context actions.
- Release pipeline now produces a ready-to-run zip that includes `vendor/`.
---
## Changes 11/10/2025 (v1.9.2)
release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback)
- New “Upload file(s)” action in Create menu:
- Adds `<li id="uploadOption">` to the dropdown.
- Opens a reusable Upload modal that *moves* the existing #uploadCard into the modal (no cloning = no lost listeners).
- ESC / backdrop / “×” close support; focus jumps to “Choose Files” for fast keyboard flow.
- Drag & Drop from file list → Upload:
- Drag-over on #fileListContainer shows drop-hover and auto-opens the Upload modal after a short hover.
- On drop, waits until the modals #uploadDropArea exists, then relays the drop to it.
- Uses a resilient relay: attempts to attach DataTransfer to a synthetic event; falls back to a stash.
- Synthetic drop fallback:
- Introduces window.__pendingDropData (cleared after use).
- upload.js now reads e.dataTransfer || window.__pendingDropData to accept relayed drops across browsers.
- Implementation details:
- fileActions.js: adds openUploadModal()/closeUploadModal() with a hidden sentinel to return #uploadCard to its original place on close.
- appCore.js: imports openUploadModal, adds waitFor() helper, and wires dragover/leave/drop logic for the relay.
- index.html: adds Upload option to the Create menu and the #uploadModal scaffold.
- UX/Safety:
- Defensive checks if modal/card isnt present.
- No backend/API changes; CSRF/auth unchanged.
Files touched: public/js/upload.js, public/js/fileActions.js, public/js/appCore.js, public/index.html
---
## Changes 11/9/2025 (v1.9.1)
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
### Highlights v1.9.1
- 🎨 Per-folder colors with live SVG preview and consistent styling in light/dark modes.
- 📄 Folder icons auto-refresh when contents change (no full page reload).
- 🧭 Drag-and-drop breadcrumb fallback for folder→folder moves.
- 🛠️ Safer upgrade helper script to rsync app files without touching data.
- feat(colors): add per-folder color customization
- New endpoints: GET /api/folder/getFolderColors.php and POST /api/folder/saveFolderColor.php
- AuthZ: reuse canRename for “customize folder”, validate hex, and write atomically to metadata/folder_colors.json.
- Read endpoint filters map by ACL::canRead before returning to the user.
- Frontend: load/apply colors to tree rows; persist on move/rename; API helpers saveFolderColor/getFolderColors.
- feat(ui): color-picker modal with live SVG folder preview
- Shows preview that updates as you pick; supports Save/Reset; protects against accidental toggle clicks.
- feat(controls): “Color folder” button in Folder Management card
- New `.btn-color-folder` with accent palette (#008CB4), hover/active/focus states, dark-mode tuning; event wiring gated by caps.
- i18n: add strings for color UI (color_folder, choose_color, reset_default, save_color, folder_color_saved, folder_color_cleared).
- ux(tree): make expansion state more predictable across refreshes
- `expandTreePath(path, {force,persist,includeLeaf})` with persistence; keep ancestors expanded; add click-suppression guard.
- ux(layout): center the folder-actions toolbar; remove left padding hacks; normalize icon sizing.
- chore(ops): add scripts/manual-sync.sh (safe rsync update path, preserves data dirs and public/.htaccess).
---
## Changes 11/9/2025 (v1.9.0)
release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening
feat(ui): modern folder tree
- New crisp folder SVG with clear paper insert; unified yellow/orange palette for light & dark
- Proper ARIA tree semantics (role=treeitem, aria-expanded), cleaner chevrons, better alignment
- Breadcrumb tweaks ( separators), hover/selected polish
- Prime icons locally, then confirm via counts for accurate “empty vs non-empty”
feat(api): add /api/folder/isEmpty.php via controller/model
- public/api/folder/isEmpty.php delegates to FolderController::stats()
- FolderModel::countVisible() enforces ACL, path safety, and short-circuits after first entry
- Releases PHP session lock early to avoid parallel-request pileups
perf: cap concurrent “isEmpty” requests + timeouts
- Small concurrency limiter + fetch timeouts
- In-memory result & inflight caches for fewer network hits
fix(state): preserve user expand/collapse choices
- Respect saved folderTreeState; dont auto-expand unopened nodes
- Only show ancestors for visibility when navigating (no unwanted persists)
security: tighten .htaccess while enabling WebDAV
- Deny direct PHP except /api/*.php, /api.php, and /webdav.php
- AcceptPathInfo On; keep path-aware dotfile denial
refactor: move count logic to model; thin controller action
chore(css): add unified “folder tree” block with variables (sizes, gaps, colors)
Files touched: FolderModel.php, FolderController.php, public/js/folderManager.js, public/css/styles.css, public/api/folder/isEmpty.php (new), public/.htaccess
---
## Changes 11/8/2025 (v1.8.13)
release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync
- dnd: fix disappearing/overlapping cards when moving between sidebar/top; return to origin on failed drop
- layout: placeCardInZone now live-updates top layout, sidebar visibility, and toggle icon
- toggle/collapse: move ALL cards to header on collapse, restore saved layout on expand; keep icon state synced; add body.sidebar-hidden for proper file list expansion; emit `zones:collapsed-changed`
- header dock: show dock whenever icons exist (and on collapse); hide when empty
- responsive: enforceResponsiveZones also updates toggle icon; stash/restore behavior unchanged
- sidebar: hard-lock width to 350px (CSS) and remove runtime 280px minWidth; add placeholder when empty to make dropping back easy
- CSS: right-align header dock buttons, centered “Drop Zone” label, sensible min-height; dark-mode safe
- refactor: small renames/ordering; remove redundant z-index on toggle; minor formatting
---
## Changes 11/8/2025 (v1.8.12)
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons
- auth (public/js/main.js)
- Robust login options: tolerate key variants (disableFormLogin/disable_form_login, etc.).
- Correctly show/hide wrapper + individual methods (form/OIDC/basic).
- Auto-SSO when OIDC is the only enabled method; add opt-out with `?noauto=1`.
- Minor cleanup (SW register catch spacing).
- drag & drop (public/js/dragAndDrop.js)
- Reworked zones model: Sidebar / Top (left/right) / Header (icon+modal).
- Persist user layout with `userZonesSnapshot.v2` and responsive stash for small screens.
- Live UI sync: toggle icon (`material-icons`) updates immediately after moves.
- Smarter small-screen behavior: lift sidebar cards ephemerally; restore only what belonged to sidebar.
- Cleaner header icon modal plumbing; remove legacy/dead code.
- styles (public/css/styles.css)
- Header drop zone fills remaining space and right-aligns its icons.
UX:
- OIDC button reliably appears when form/basic are disabled.
- If OIDC is the sole method, users are taken straight to the provider (unless `?noauto=1`).
- Header icons sit with the other header actions (right-aligned), and the toggle icon reflects layout changes instantly.
---
## Changes 11/8/2025 (v1.8.11)
release(v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client
- Force PKCE via setCodeChallengeMethod('S256') so Authelias public-client policy is satisfied.
- Convert empty OIDC client secret to null to correctly signal a public client.
- Optional commented hook to switch token endpoint auth to client_secret_post if desired.
- OIDC_TOKEN_ENDPOINT_AUTH_METHOD added to config.php
---
## Changes 11/8/2025 (v1.8.10)
release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul
UI/UX — Media modal
- Add fixed top bar to avoid filename/controls overlapping native media chrome; keep hover-on-stage look.
- Show a Material icon by file type next to the filename (image/video/pdf/code/arch/txt, with fallback).
- Restore “X” behavior and make hover theme-aware (red pill + white X in light, red pill + black X in dark).
Video/Image controls
- Top-right action icons use theme-aware styles and align with the filename row.
- Prev/Next paddles remain high-contrast and vertically centered within the stage.
Progress badges (list & modal)
- Standardize “in-progress” to darker orange (#ea580c) for better contrast in light/dark; update CSS and list badge rendering.
Drag & drop
- Support multi-select drags with a clean JSON payload + text fallback; nicer drag ghost.
- More resilient drops: accept data-dest-folder, safer JSON parse, early guards, and better toasts.
- POST move now sends Accept header, uses global CSRF, and refreshes the active view on success.
Editor & ONLYOFFICE
- Full-screen OO modal with preconnect, optional hidden warm-up to reduce first-open latency, and live theme sync.
- CodeMirror path: fix theme/mode setters (use `cm`) and tighten dynamic mode loading.
Assets & polish
- Swap in full favicon stack (SVG + PNG 512/32/16 + ICO) and set theme-color; cache-busted via `{{APP_QVER}}`.
- Refresh `logo.svg` (accessibility, cleaner handles/gradients).
Also added: refreshed resource images and new logo sizes (logo-16, logo-32, logo-64, etc.) for crisper favicons and embeds.
---
## Changes 11/7/2025 (v1.8.9)
release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64)
- adminPanel.js:
- Masked inputs without a saved value now start with data-replace="1".
- handleSave() now sends oidc.clientId / oidc.clientSecret on first save (no longer requires clicking “Replace” first).
---
## Changes 11/7/2025 (v1.8.8)
release(v1.8.8): background ZIP jobs w/ tokenized download + inmodal progress bar; robust finalize; janitor cleanup — closes #60
**Summary**
This release moves ZIP creation off the request thread into a **background worker** and switches the client to a **queue > poll > tokenized GET** download flow. It fixes large multiGB ZIP failures caused by request timeouts or crossdevice renames, and provides a resilient inmodal progress experience. It also adds a 6hour janitor for temporary tokens/logs.
**Backend** changes:
- Add **zip status** endpoint that returns progress and readiness, and **tokenized download** endpoint for oneshot downloads.
- Update `FileController::downloadZip()` to enqueue a job and return `{ token, statusUrl, downloadUrl }` instead of streaming a blob in the POST response.
- Implement `spawnZipWorker()` to find a working PHP CLI, set `TMPDIR` on the same filesystem as the final ZIP, spawn with `nohup`, and persist PID/log metadata for diagnostics.
- Serve finished ZIPs via `downloadZipFile()` with strict token/user checks and streaming headers; unlink the ZIP after successful read.
New **Worker**:
- New `src/cli/zip_worker.php` builds the archive in the background.
- Writes progress fields (`pct`, `filesDone`, `filesTotal`, `bytesDone`, `bytesTotal`, `current`, `phase`, `startedAt`, `finalizeAt`) to the pertoken JSON.
- During **finalizing**, publishes `selectedFiles`/`selectedBytes` and clears incremental counters to avoid the confusing “N/N files” display before `close()` returns.
- Adds a **janitor**: purge `.tokens/*.json` and `.logs/WORKER-*.log` older than **6 hours** on each run.
New **API/Status Payload**:
- `zipStatus()` exposes `ready` (derived from `status=done` + existing `zipPath`), and includes `startedAt`/`finalizeAt` for UI timers.
- Returns a prebuilt `downloadUrl` for a direct handoff once the ZIP is ready.
**Frontend (UX)** changes:
- Replace blob POST download with **enqueue → poll → tokenized GET** flow.
- Native `<progress>` bar now renders **inside the modal** (no overflow/jitter).
- Shows determinate **098%** during enumeration, then **locks at 100%** with **“Finalizing… mm:ss — N files, ~Size”** until the download starts.
- Modal closes just before download; UI resets for the next operation.
Added **CSS**:
- Ensure the progress modal has a minimum height and hidden overflow; ellipsize the status line to prevent scrollbars.
**Why this closes #60**?
- ZIP creation no longer depends on the request lifetime (avoids proxy/Apache timeouts).
- Temporary files and final ZIP are created on the **same filesystem** (prevents “rename temp file failed” during `ZipArchive::close()`).
- Users get continuous, truthful feedback for large multiGB archives.
Additional **Notes**
- Download tokens are **oneshot** and are deleted after the GET completes.
- Temporary artifacts (`META_DIR/ziptmp/.tokens`, `.logs`, and old ZIPs) are cleaned up automatically (≥6h).
---
## Changes 11/5/2025 (v1.8.7)
release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives
- FileController::downloadZip
- Remove _jsonStart/_jsonEnd and JSON wrappers; send a pure binary ZIP
- Close session locks, disable gzip/output buffering, set Content-Length when known
- Stream in 1MiB chunks; proper HTTP codes/messages on errors
- Unlink the temp ZIP after successful send
- Preserves all auth/ACL/ownership checks
- FileModel::createZipArchive
- Purge META_DIR/ziptmp/download-*.zip older than 6h before creating a new ZIP
Result: fixes “failed to fetch / load failed” with fetch>blob flow and reduces leftover tmp ZIPs.
---
## Changes 11/4/2025 (v1.8.6)
release(v1.8.6): fix large ZIP downloads + safer extract; close #60
- Zip creation
- Write archives to META_DIR/ziptmp (on large/writable disk) instead of system tmp.
- Auto-create ziptmp (0775) and verify writability.
- Free-space sanity check (~files total +5% +20MB); clearer error on low space.
- Normalize/validate folder segments; include only regular files.
- set_time_limit(0); use CREATE|OVERWRITE; improved error handling.
- Zip extraction
- New: stamp metadata for files in nested subfolders (per-folder metadata.json).
- Skip hidden “dot” paths (files/dirs with any segment starting with “.”) by default
via SKIP_DOTFILES_ON_EXTRACT=true; only extract allow-listed entries.
- Hardenings: zip-slip guard, reject symlinks (external_attributes), zip-bomb limits
(MAX_UNZIP_BYTES default 200GiB, MAX_UNZIP_FILES default 20k).
- Persist metadata for all touched folders; keep extractedFiles list for top-level names.
Ops note: ensure /var/www/metadata/ziptmp exists & is writable (or mount META_DIR to a large volume).
Closes #60.
---
## Changes 11/4/2025 (v1.8.5)
release(v1.8.5): ci: reduce pre-run delay to 2-min and add missing `needs: delay`, final test
- No change release just testing
---
## Changes 11/4/2025 (v1.8.4)
release(v1.8.4): ci: add 3-min pre-run delay to avoid workflow_run races
- No change release just testing
---
## Changes 11/4/2025 (v1.8.3)
release(v1.8.3): feat(mobile+ci): harden Capacitor switcher & make release-on-version robust
- switcher.js: allow running inside Capacitor; remove innerHTML usage; build nodes safely; normalize/strip creds from URLs; add withParam() for ?frapp=1; drop inline handlers; clamp rename length; minor UX polish.
- CI: cancel superseded runs per ref; checkout triggering commit (workflow_run head_sha); improve APP_VERSION parsing; point tag to checked-out commit; add recent-tag debug.
---
## Changes 11/4/2025 (v1.8.2)
release(v1.8.2): media progress tracking + watched badges; PWA scaffolding; mobile switcher (closes #37)
- **Highlights**
- Video: auto-save playback progress and mark “Watched”, with resume-on-open and inline status chips on list/gallery.
- Mobile: introduced FileRise Mobile (Capacitor) companion repo + in-app server switcher and PWA bits.
- **Details**
- API (new):
- POST /api/media/updateProgress.php — persist per-user progress (seconds/duration/completed).
- GET /api/media/getProgress.php — fetch per-file progress.
- GET /api/media/getViewedMap.php — folder map for badges.
- **Frontend (media):**
- Video previews now resume from last position, periodically save progress, and mark completed on end, with toasts.
- Added status badges (“Watched” / %-complete) in table & gallery; CSS polish for badges.
- Badges render during list/gallery refresh; safer filename wrapping for badge injection.
- **Mobile & PWA:**
- New in-app server switcher (Capacitor-aware) loaded only in app/standalone contexts.
- Service Worker + manifest added (root scope via /public/sw.js; worker body in /js/pwa/sw.js; manifest icons).
- main.js conditionally imports the mobile switcher and registers the SW on web origins only.
- **Notes**
- Companion repo: **filerise-mobile** (Capacitor app shell) created for iOS/Android distribution.
- No breaking changes expected; endpoints are additive.
Closes #37.
---
## Changes 11/3/2025 (V1.8.1)
release(v1.8.1): fix(security,onlyoffice): sanitize DS origin; safe api.js/iframe probes; better UX placeholder
- Add ONLYOFFICE URL sanitizers:
- getTrustedDocsOrigin(): enforce http/https, strip creds, normalize to origin
- buildOnlyOfficeApiUrl(): construct fixed /web-apps/.../api.js via URL()
- Probe hardening (addresses CodeQL js/xss-through-dom):
- ooProbeScript/ooProbeFrame now use sanitized origins and fixed paths
- optional CSP nonce support for injected script
- optional iframe sandbox; robust cleanup/timeout handling
- CSP helper now renders lines based on validated origin (fallback to raw for visibility)
- Admin UI UX: placeholder switched to HTTPS example (`https://docs.example.com`)
- Comments added to justify safety to static analyzers
Files: public/js/adminPanel.js
Refs: #37
---
## Changes 11/3/2025 (v1.8.0)
release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers
Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately.
Adds secure, ACL-aware ONLYOFFICE support throughout FileRise:
- **Backend / API**
- New OnlyOfficeController with supported extensions (doc/xls/ppt/pdf etc.), status/config endpoints, and signed download flow.
- New endpoints:
- GET /api/onlyoffice/status.php — reports availability + supported exts.
- GET /api/onlyoffice/config.php — returns DocEditor config (signed URLs, callback).
- GET /api/onlyoffice/signed-download.php — serves signed blobs to DS.
- Effective config/overrides: env/constant wins; supports docsOrigin, publicOrigin, and jwtSecret; status gated on presence of origin+secret.
- Public origin resolution (BASE_URL/proxy aware) for absolute URLs.
- **Admin config / UI**
- AdminPanel gets a new “ONLYOFFICE” section with Enable toggle, Document Server Origin, masked JWT Secret, and “Replace” control.
- Built-in connection tester (status, secret presence, callback ping, api.js load, iframe embed) + CSP helper (Apache & Nginx snippets)
- **Frontend integration**
- fileEditor detects OO capability via /api/onlyoffice/status and routes supported types to the DocEditor; loads DocsAPI dynamically.
- editFile() short-circuits to openOnlyOffice when applicable; includes live dark/light theme sync where supported.
- fileListView pulls status once on load to drive UI decisions (e.g., editing affordances).
- **AdminModel / config**
- Adds onlyoffice {enabled, docsOrigin, publicOrigin} defaults and update path, with jwtSecret persisted (kept unless explicitly replaced).
- Optional constants in config.php to override and debug.
- **Security & UX notes**
- Editor access remains ACL-checked (read/edit) and uses absolute, signed URLs surfaced via controller.
- Admin UI never echoes secrets; “Replace” toggles explicit updates only.
- CSP helper makes it straightforward to permit api.js + iframe + XHR to your DS.
- **Docs/Styling**
- Minor CSS touch-ups around hover states and modal layout.
---
## Changes 11/2/2025 (v1.7.5)
release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50)
release(v1.7.5): retrigger CI bump (no code changes)
release(v1.7.5): retrigger CI bump ensure up to date
### Security/headers
- Tighten CSP: pin the inline pre-theme snippet with a script-src SHA-256 and keep everything else on 'self'.
- Improve cache policy for versioned assets: force 1y + immutable and add s-maxage for CDNs; also avoid HSTS redirects on local/dev hosts.
### Previews & editor
- Remove hardcoded `/uploads/` paths; always build preview URLs via the API (respects UPLOAD_DIR/ACL).
- Use the API URL for gallery prev/next and file-menu “Preview” to fix 404s on custom storage roots.
- Editor now probes size safely (HEAD → Range 0-0 fallback) before fetching, then fetches with credentials.
### Login, theming & UX polish
- Pre-theme inline boot sets `dark-mode` + background early; swap to `[hidden]`/`unhide()` instead of inline `display:none`.
- Add full-screen loading overlay with quick fade and proper color-scheme; prevent white/black flash on theme flips.
- Refactor app/login reveal flow in `main.js` (`revealAppAndHideOverlay`, `authed` path, setup wizard).
### HTML/CSS & perf
- Make Bootstrap/Styles/Roboto critical (plain `<link rel="stylesheet">`); keep fonts as true preloads; modulepreload app entry.
- Export a `__CSS_PROMISE__` from `defer-css.js` for sites that still promote preloads.
- Header logo marked `fetchpriority="high"` for faster first paint.
- Normalize dark-mode selectors to `.dark-mode` scope (admin panel, etc.).
### Manual Deploy script
- Add `scripts/filerise-deploy.sh`: idempotent rsync-based deploy with writable dirs preserved, optional Composer install, and PHP-FPM/Apache reloads.
### Notes
- If you change the inline pre-theme snippet, update the CSP hash accordingly.
---
## Changes 10/31/2025 (v1.7.4)
release(v1.7.4): login hint replace toast + fix unauth boot
main.js
- Added isDemoHost() and showLoginTip(message).
- In the unauth branch, call showLoginTip('Please log in to continue').
- Removed ensureToastReady() + showToast('please_log_in_to_continue') in the unauth path to avoid loading toast/DOM utils before auth.
---
## Changes 10/31/2025 (v1.7.3)
release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth
### 🎃 Highlights (advantages) 👻 🦇
- ⚡ Faster, cleaner boot: a lightweight **main.js** decides auth/setup before painting, avoids flicker, and wires modules exactly once.
- ♻️ Fewer duplicate actions: **request coalescer** dedupes POST/PUT/PATCH/DELETE to /api/* .
- ✅ Truthy UX: global **toast bridge** queues early toasts and normalizes misleading “not found/already exists” messages after success.
- 🔐 Smoother auth: CSRF priming/rotation + **TOTP step-up detection** across JSON & redirect paths; “Welcome back, `user`” toast once per tab.
- 🌓 Polished UI: **dark-mode persistence with system fallback**, live siteConfig title application, higher-z modals, drag auto-scroll.
- 🚀 Faster first paint & interactions: defer CodeMirror/Fuse/Resumable, promote preloaded CSS, and coalesce duplicate requests → snappier UI.
- 🧭 Admin polish: live header title preview, masked OIDC fields with **Replace** flow, and a **read-only Sponsors/Donations** section.
- 🧱 Safer & cache-smarter: opinionated .htaccess (CSP/HSTS/MIME/compression) + `?v={{APP_QVER}}` for versioned immutable assets.
### Core bootstrap (main.js) overhaul
- Early **toast bridge** (queues until domUtils is ready); expose `window.__FR_TOAST_FILTER__` for centralized rewrites/suppression.
- **Result guard + request coalescer** wrapping `fetch`:
- Dedupes same-origin `/api/*` mutating requests for ~800ms using a stable key (method + path + normalized body).
- Tracks “last OK” JSON (`success|status|result=ok`) to suppress false-negative error toasts after success.
- **Boot orchestrator** with hard guards:
- `__FR_FLAGS` (`booted`, `initialized`, `wired.*`, `bootPromise`, `entryStarted`) to prevent double init/leaks.
- **No-flicker login**: resolve `checkAuth()` + `setup` before showing UI; show login only when truly unauthenticated.
- **Heavy boot** for authed users: load i18n, `appCore.loadCsrfToken/initializeApp`, first file list, then light UI wiring.
- **Auth flow**:
- `primeCsrf()` + `<meta name="csrf-token">` management; persist token in localStorage.
- **TOTP** detection via header (`X-TOTP-Required`) & JSON (`totp_required` / `TOTP_REQUIRED`); calls `openTOTPLoginModal()`.
- **Welcome toast** once per tab via `sessionStorage.__fr_welcomed`.
- **UI/UX niceties**:
- `applySiteConfig()` updates header title & login method visibility on both login & authed screens.
- Dark-mode persistence with system fallback, proper a11y labels/icons.
- Create dropdown/menu wiring with capture-phase outside-click + ESC close; modal cancel safeties.
- Lift modals above cards (z-index), **drag auto-scroll** near viewport edges.
- Dispatch legacy `DOMContentLoaded`/`load` **once** (supports older inline handlers).
- Username label refresh for existing `.user-name-label` without injecting new DOM.
### Performance & UX changes
- CSS/first paint:
- Preload Bootstrap & app CSS; promote at DOMContentLoaded; keep inline CSS minimal.
- Add `width/height/decoding/fetchpriority` to logo to reduce layout shift.
- Search/editor/uploads:
- **fileListView.js**: lazy-load Fuse with instant substring fallback; `warmUpSearch()` hook.
- **fileEditor.js**: lazy-load CodeMirror core/theme/modes; start plain then upgrade; guard very large files gracefully.
- **upload.js**: lazy-load Resumable; resilient init; background warm-up; smarter addFile/submit; clearer toasts.
- Toast/UX:
- Install early toast bridge; queue & normalize messages; neutral “Done.” when server returns misleading errors after success.
### Correctness: uploads, paths, ACLs
- **UploadController/UploadModel**: normalize folders via `ACL::normalizeFolder(rawurldecode())`; stricter segment checks; consistent base paths; safer metadata writes; proper chunk presence/merge & temp cleanup.
### Auth hardening & resilience
- **auth.js/main.js/appCore.js**: CSRF rotate/retry (JSON then x-www-form-urlencoded fallback); robust login handling; fewer misleading error toasts.
- **AuthController**: OIDC username fallback to `email` or `sub` when `preferred_username` missing.
### Admin panel
- **adminPanel.js**:
- Live header title preview (instant update without reload).
- Masked OIDC client fields with **Replace** button; saved-value hints; only send secrets when replacing.
- **New “Sponsor / Donations” section (read-only)**:
- GitHub Sponsors → `https://github.com/sponsors/error311`
- Ko-fi → `https://ko-fi.com/error311`
- Includes **Copy** and **Open** buttons; values are fixed.
- **AdminController**: boolean for `oidc.hasClientId/hasClientSecret` to drive masked inputs.
### Security & caching (.htaccess)
- Consolidated security headers (CSP, CORP, HSTS on HTTPS), MIME types, compression (Brotli/Deflate), TRACE disable.
- Caching rules:
- HTML/version.js: no-cache; unversioned JS/CSS: 1h; unversioned static: 7d; **versioned assets `?v=`: 1y `immutable`**.
- **config.php**: remove duplicate runtime headers (now via Apache) to avoid proxy/CDN conflicts.
### Upgrade notes
- No schema changes.
- Ensure Apache modules (`headers`, `rewrite`, `brotli`/`deflate`) are available for the new .htaccess rules (fallbacks included).
- Versioned assets mean users shouldnt need a hard refresh; `?v={{APP_QVER}}` busts caches automatically.
---
## Changes 10/29/2025 (v1.7.0 & v1.7.1 & v1.7.2)
release(v1.7.0): asset cache-busting pipeline, public siteConfig cache, JS core split, and caching/security polish
### ✨ Features
- Public, non-sensitive site config cache:
- Add `AdminModel::buildPublicSubset()` and `writeSiteConfig()` to write `USERS_DIR/siteConfig.json`.
- New endpoint `public/api/siteConfig.php` + `UserController::siteConfig()` to serve the public subset (regenerates if stale).
- Frontend now reads `/api/siteConfig.php` (safe subset) instead of `/api/admin/getConfig.php`.
- Frontend module versioning:
- Replace all module imports with `?v={{APP_QVER}}` query param so the release/Docker stamper can pin exact versions.
- Add `scripts/stamp-assets.sh` to stamp `?v=` and `{{APP_VER}}/{{APP_QVER}}` in **staging** for ZIP/Docker builds.
### 🧩 Refactors
- Extract shared boot/bootstrap logic into `public/js/appCore.js`:
- CSRF helpers (`setCsrfToken`, `getCsrfToken`, `loadCsrfToken`)
- `initializeApp()`, `triggerLogout()`
- Keep `main.js` lean; wrap global `fetch` once to append/rotate CSRF.
- Update imports across JS modules to use versioned module URLs.
### 🚀 Performance
- Aggressive, safe caching for versioned assets:
- `.htaccess`: `?v=…``Cache-Control: max-age=31536000, immutable`.
- Unversioned JS/CSS short cache (1h), other static (7d).
- Eliminate duplicate `main.js` loads and tighten CodeMirror mode loading.
### 🔒 Security / Hardening
- `.htaccess`:
- Conditional HSTS only when HTTPS, add CORP and X-Permitted-Cross-Domain-Policies.
- CSP kept strict for modules, workers, blobs.
- Admin config exposure reduced to a curated subset in `siteConfig.json`.
### 🧪 CI/CD / Release
- **FileRise repo**
- `sync-changelog.yml`: keep `public/js/version.js` as source-of-truth only (no repo-wide stamping).
- `release-on-version.yml`: build **stamped** ZIP from a staging copy via `scripts/stamp-assets.sh`, verify placeholders removed, attach checksum.
- **filerise-docker repo**
- Read `VERSION`, checkout app to `app/`, run stamper inside build context before `docker buildx`, tag `latest` and `:${VERSION}`.
### 🔧 Defaults
- Sample/admin config defaults now set `disableBasicAuth: true` (safer default). Existing installations keep their current setting.
### 📂 Notable file changes
- `src/models/AdminModel.php` (+public subset +atomic write)
- `src/controllers/UserController.php` (+siteConfig action)
- `public/api/siteConfig.php` (new)
- `public/js/appCore.js` (new), `public/js/main.js` (slim, uses appCore)
- Many `public/js/*.js` import paths updated to `?v={{APP_QVER}}`
- `public/.htaccess` (caching & headers)
- `scripts/stamp-assets.sh` (new)
### ⚠️ Upgrade notes
- Ensure `USERS_DIR` is writable by web server for `siteConfig.json`.
- Proxies/edge caches: the new `?v=` scheme enables long-lived immutable caching; purge is automatic on version bump.
- If you previously read admin config directly on the client, it now reads `/api/siteConfig.php`.
### Additional changes/fixes for release
- `release-on-version.yml`
- normalize line endings (strip CRLF)
- stamp-assets.sh dont rely on the exec; invoke via bash
release(v1.7.2): harden asset stamping & CI verification
### build(stamper)
- Rewrite scripts/stamp-assets.sh to be repo-agnostic and macOS/Windows friendly:
- Drop reliance on git ls-files/mapfile; use find + null-delimited loops
- Normalize CRLF to LF for all web assets before stamping
- Stamp ?v=<APP_QVER> in HTML/CSS/PHP and {{APP_VER}} everywhere
- Normalize any ".mjs|.js?v=..." occurrences inside JS (ESM imports/strings)
- Force-write public/js/version.js from VER (source of truth in stamped output)
- Print touched counts and fail fast if any {{APP_QVER}}|{{APP_VER}} remain
---
## Changes 10/28/2025 (v1.6.11)
release(v1.6.11) fix(ui/dragAndDrop) restore floating zones toggle click action
Re-add the click handler to toggle `zonesCollapsed` so the header
“sidebarToggleFloating” button actually expands/collapses the zones
again. This regressed in v1.6.10 during auth-gating refactor.
Refs: #regression #ux
chore(codeql): move config to repo root for default setup
- Relocate .github/codeql/codeql-config.yml to codeql-config.yml so GitHub default code scanning picks it up
- Keep paths: public/js, api
- Keep ignores: public/vendor/**, public/css/vendor/**, public/fonts/**, public/**/*.min.{js,css}, public/**/*.map
---
## Changes 10/28/2025 (v1.6.10)
release(v1.6.10): self-host ReDoc, gate sidebar toggle on auth, and enrich release workflow
- Vendor ReDoc and add MIT license file under public/vendor/redoc/; switch api.php to local bundle to satisfy CSP (script-src 'self').
- main.js: add/remove body.authenticated on login/logout so UI can reflect auth state.
- dragAndDrop.js: only render sidebarToggleFloating when authenticated; stop event bubbling, keep dark-mode styles.
- sync-changelog.yml: also stamp ?v= in PHP templates (public/**/*.php).
- release-on-version.yml: build zip first, compute SHA-256, assemble release body with latest CHANGELOG snippet, “Full Changelog” compare link, and attach .sha256 alongside the zip.
- THIRD_PARTY.md: document ReDoc vendoring and rationale.
Refs: #security #csp #release
---
## Changes 10/27/2025 (v1.6.9)
release(v1.6.9): feat(core) localize assets, harden headers, and speed up load
- index.html: drop all CDNs in favor of local /vendor assets
- add versioned cache-busting query (?v=…) on CSS/JS
- wire version.js for APP_VERSION and numeric cache key
- public/vendor/: add pinned copies of:
- bootstrap 4.5.2, codemirror 5.65.5 (+ themes/modes), dompurify 2.4.0,
fuse.js 6.6.2, resumable.js 1.1.0
- fonts: add self-hosted Material Icons + Roboto (latin + latin-ext) with
vendor CSS (material-icons.css, roboto.css)
- fileEditor.js: load CodeMirror modes from local vendor with ?v=APP_VERSION_NUM,
keep timeout/plain-text fallback, no SRI (same-origin)
- dragAndDrop.js: nudge zonesToggle 65px left to sit tighter to the logo
- styles.css: prune/organize rules and add small utility classes; move 3P
font CSS to /css/vendor/
- .htaccess: security + performance overhaul
- Content-Security-Policy: default-src 'self'; img-src include data: and blob:
- version-aware caching: HTML/version.js = no-cache; assets with ?v= = 1y immutable
- correct MIME for fonts/SVG; enable Brotli/Gzip (if available)
- X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS, Permissions-Policy
- disable TRACE; deny dotfiles; prevent directory listing
- .gitattributes: mark vendor/minified as linguist-vendored, treat assets as
binary in diffs, exclude CI/resources from source archives
- docs/licensing:
- add licenses/ and THIRD_PARTY.md with upstream licenses/attribution
- README: add “License & Credits” section with components and licenses
- CI: (sync-changelog) stamp asset cache-busters to the numeric release
(e.g. ?v=1.6.9) and write window.APP_VERSION in version.js before Docker build
perf: site loads significantly faster with local assets + compression + long-lived caching
security: CSP, strict headers, and same-origin assets reduce XSS/SRI/CORS risk
Refs: #performance #security
---
## Changes 10/25/2025 (v1.6.8)
release(v1.6.8): fix(ui) prevent Extract/Create flash on refresh; remember last folder
- Seed `currentFolder` from `localStorage.lastOpenedFolder` (fallback to "root")
- Stop eager `loadFileList('root')` on boot; defer initial load to resolved folder
- Hide capability-gated actions by default (`#extractZipBtn`, `#createBtn`) to avoid pre-auth flash
- Eliminates transient root state when reloading inside a subfolder
User-visible: refreshing a non-root folder no longer flashes Root items or privileged buttons; app resumes in the last opened folder.
---
## Changes 10/25/2025 (v1.6.7)
release(v1.6.7): Folder Move feature, stable DnD persistence, safer uploads, and ACL/UI polish
### 📂 Folder Move (new major feature)
**Drag & Drop to move folder, use context menu or Move Folder button**
- Added **Move Folder** support across backend and UI.
- New API endpoint: `public/api/folder/moveFolder.php`
- Controller and ACL updates to validate scope, ownership, and permissions.
- Non-admins can only move within folders they own.
- `ACL::renameTree()` re-keys all subtree ACLs on folder rename/move.
- Introduced new capabilities:
- `canMoveFolder`
- `canMove` (UI alias for backward compatibility)
- New “Move Folder” button + modal in the UI with full i18n strings (`i18n.js`).
- Action button styling and tooltip consistency for all folder actions.
### 🧱 Drag & Drop / Layout Improvements
- Fixed **random sidebar → top zone jumps** on refresh.
- Cards/panels now **persist exactly where you placed them** (`userZonesSnapshot`)
— no unwanted repositioning unless the window is resized below the small-screen threshold.
- Added hysteresis around the 1205 px breakpoint to prevent flicker when resizing.
- Eliminated the 50 px “ghost” gutter with `clampSidebarWhenEmpty()`:
- Sidebar no longer reserves space when collapsed or empty.
- Temporarily “unclamps” during drag so drop targets remain accurate and full-width.
- Removed forced 800 px height on drag highlight; uses natural flex layout now.
- General layout polish — smoother transitions when toggling *Hide/Show Panels*.
### ☁️ Uploads & UX
- Stronger folder sanitization and safer base-path handling.
- Fixed subfolder creation when uploading directories (now builds under correct parent).
- Improved chunk error handling and metadata key correctness.
- Clearer success/failure toasts and accurate filename display from server responses.
### 🔐 Permissions / ACL
- Simplified file rename checks — now rely solely on granular `ACL::canRename()`.
- Updated capability lists to include move/rename operations consistently.
### 🌐 UI / i18n Enhancements
- Added i18n strings for new “Move Folder” prompts, modals, and tooltips.
- Minor UI consistency tweaks: button alignment, focus states, reduced-motion support.
---
## Changes 10/24/2025 (v1.6.6)
release(v1.6.6): header-mounted toggle, dark-mode polish, persistent layout, and ACL fix
- dragAndDrop: mount zones toggle beside header logo (absolute, non-scrolling);
stop click propagation so it doesnt trigger the logo link; theme-aware styling
- live updates via MutationObserver; snapshot card locations on drop and restore
on load (prevents sidebar reset); guard first-run defaults with
`layoutDefaultApplied_v1`; small/medium layout tweaks & refactors.
- CSS: switch toggle icon to CSS variable (`--toggle-icon-color`) with dark-mode
override; remove hardcoded `!important`.
- API (capabilities.php): remove unused `disableUpload` flag from `canUpload`
and flags payload to resolve undefined variable warning.
---
## Changes 10/24/2025 (v1.6.5)
release(v1.6.5): fix PHP warning and upload-flag check in capabilities.php
- Fix undefined variable: use $disableUpload consistently
- Harden flag read: (bool)($perms['disableUpload'] ?? false)
- Prevents warning and ensures Upload capability is computed correctly
---
## Changes 10/24/2025 (v1.6.4)
release(v1.6.4): runtime version injection + CI bump/sync; caching tweaks
- Add public/js/version.js (default "dev") and load it before main.js.
- adminPanel.js: replace hard-coded string with `window.APP_VERSION || "dev"`.
- public/.htaccess: add no-cache for js/version.js
- GitHub Actions: replace sync job with “Bump version and sync Changelog to Docker Repo”.
- Parse commit msg `release(vX.Y.Z)` -> set step output `version`.
- Write `public/js/version.js` with `window.APP_VERSION = '<version>'`.
- Commit/push version.js if changed.
- Mirror CHANGELOG.md to filerise-docker and write a VERSION file with `<version>`.
- Guard all steps with `if: steps.ver.outputs.version != ''` to no-op on non-release commits.
This wires the UI version label to CI, keeps dev builds showing “dev”, and feeds the Docker repo with CHANGELOG + VERSION for builds.
---
## Changes 10/24/2025 (v1.6.3)
release(v1.6.3): drag/drop card persistence, admin UX fixes, and docs (closes #58)
Drag & Drop - Upload/Folder Management Cards layout
- Persist panel locations across refresh; snapshot + restore when collapsing/expanding.
- Unified “zones” toggle; header-icon mode no longer loses card state.
- Responsive: auto-move sidebar cards to top on small screens; restore on resize.
- Better top-zone placeholder/cleanup during drag; tighter header modal sizing.
- Safer order saving + deterministic placement for upload/folder cards.
Admin Panel Folder Access
- Fix: newly created folders now appear without a full page refresh (cache-busted `getFolderList`).
- Show admin users in the list with full access pre-applied and inputs disabled (read-only).
- Skip sending updates for admins when saving grants.
- “Folder” column now has its own horizontal scrollbar so long names / “Inherited from …” are never cut off.
Admin Panel User Permissions (flags)
- Show admins (marked as Admin) with all switches disabled; exclude from save payload.
- Clarified helper text (account-level vs per-folder).
UI/Styling
- Added `.folder-cell` scroller in ACL table; improved dark-mode scrollbar/thumb.
Docs
- README edits:
- Clarified PUID/PGID mapping and host/NAS ownership requirements for mounted volumes.
- Environment variables section added
- CHOWN_ON_START additional details
- Admin details
- Upgrade section added
- 💖 Sponsor FileRise section added
---
## Changes 10/23/2025 (v1.6.2) ## Changes 10/23/2025 (v1.6.2)
feat(i18n,auth): add Simplified Chinese (zh-CN) and expose in User Panel feat(i18n,auth): add Simplified Chinese (zh-CN) and expose in User Panel

143
README.md
View File

@@ -7,8 +7,10 @@
[![Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://demo.filerise.net) [![Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://demo.filerise.net)
[![Release](https://img.shields.io/github/v/release/error311/FileRise?include_prereleases&sort=semver)](https://github.com/error311/FileRise/releases) [![Release](https://img.shields.io/github/v/release/error311/FileRise?include_prereleases&sort=semver)](https://github.com/error311/FileRise/releases)
[![License](https://img.shields.io/github/license/error311/FileRise)](LICENSE) [![License](https://img.shields.io/github/license/error311/FileRise)](LICENSE)
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-❤-red)](https://github.com/sponsors/error311)
[![Support on Ko-fi](https://img.shields.io/badge/Ko--fi-Buy%20me%20a%20coffee-orange)](https://ko-fi.com/error311)
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting) **Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [ONLYOFFICE](#quick-start-onlyoffice-optional) • [FAQ](#faq--troubleshooting)
**Elevate your File Management** A modern, self-hosted web file manager. **Elevate your File Management** A modern, self-hosted web file manager.
Upload, organize, and share files or folders through a sleek, responsive web interface. Upload, organize, and share files or folders through a sleek, responsive web interface.
@@ -19,14 +21,13 @@ Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted. With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If youre on ≤1.4.x, please upgrade. Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
**4/3/2025 Video demo:** **10/25/2025 Video demo:**
<https://github.com/user-attachments/assets/221f6a53-85f5-48d4-9abe-89445e0af90e> <https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
**Dark mode:** ![filerise-v1 9 0](https://github.com/user-attachments/assets/a346dd8a-eef1-4180-8140-4c1c08e6026e)
![Dark Header](https://raw.githubusercontent.com/error311/FileRise/refs/heads/master/resources/dark-header.png)
--- ---
@@ -72,6 +73,8 @@ With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2
- 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers. - 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers.
- 🧩 **Office Docs (ONLYOFFICE, optional):** View/edit DOCX, XLSX, PPTX (and ODT/ODS/ODP, PDF view) using your self-hosted ONLYOFFICE Document Server. Enforced by the same ACLs as the web UI & WebDAV.
- 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents. - 🏷️ **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). - 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak).
@@ -103,6 +106,22 @@ Deploy FileRise using the **Docker image** (quickest) or a **manual install** on
--- ---
### 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) ### 1) Running with Docker (Recommended)
#### Pull the image #### Pull the image
@@ -121,7 +140,7 @@ docker run -d \
-e DATE_TIME_FORMAT="m/d/y h:iA" \ -e DATE_TIME_FORMAT="m/d/y h:iA" \
-e TOTAL_UPLOAD_SIZE="5G" \ -e TOTAL_UPLOAD_SIZE="5G" \
-e SECURE="false" \ -e SECURE="false" \
-e PERSISTENT_TOKENS_KEY="please_change_this_@@" \ -e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
-e PUID="1000" \ -e PUID="1000" \
-e PGID="1000" \ -e PGID="1000" \
-e CHOWN_ON_START="true" \ -e CHOWN_ON_START="true" \
@@ -133,6 +152,8 @@ docker run -d \
error311/filerise-docker:latest 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.
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`. This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
**Notes** **Notes**
@@ -155,10 +176,10 @@ docker exec -it filerise id www-data
Save as `docker-compose.yml`, then `docker-compose up -d`: Save as `docker-compose.yml`, then `docker-compose up -d`:
```yaml ```yaml
version: "3"
services: services:
filerise: filerise:
image: error311/filerise-docker:latest image: error311/filerise-docker:latest
container_name: filerise
ports: ports:
- "8080:80" - "8080:80"
environment: environment:
@@ -166,7 +187,7 @@ services:
DATE_TIME_FORMAT: "m/d/y h:iA" DATE_TIME_FORMAT: "m/d/y h:iA"
TOTAL_UPLOAD_SIZE: "10G" TOTAL_UPLOAD_SIZE: "10G"
SECURE: "false" SECURE: "false"
PERSISTENT_TOKENS_KEY: "please_change_this_@@" PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
# Ownership & indexing # Ownership & indexing
PUID: "1000" # Unraid users often use 99 PUID: "1000" # Unraid users often use 99
PGID: "1000" # Unraid users often use 100 PGID: "1000" # Unraid users often use 100
@@ -178,11 +199,14 @@ services:
- ./uploads:/var/www/uploads - ./uploads:/var/www/uploads
- ./users:/var/www/users - ./users:/var/www/users
- ./metadata:/var/www/metadata - ./metadata:/var/www/metadata
restart: unless-stopped
``` ```
Access at `http://localhost:8080` (or your servers IP). Access at `http://localhost:8080` (or your servers IP).
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string. 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** **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. On first launch, if no users exist, youll be prompted to create an **Admin account**. Then use **User Management** to add more users.
@@ -247,6 +271,13 @@ Browse to your FileRise URL; youll be prompted to create the Admin user on fi
--- ---
### 3) Admins
> **Admins in ACL UI**
> Admin accounts appear in the Folder Access and User Permissions modals as **read-only** with full access implied. This is by design—admins always have full control and are excluded from save payloads.
---
## Unraid ## Unraid
- Install from **Community Apps** → search **FileRise**. - Install from **Community Apps** → search **FileRise**.
@@ -256,6 +287,16 @@ Browse to your FileRise URL; youll be prompted to create the Admin user on fi
--- ---
## Upgrade
```bash
docker pull error311/filerise-docker:latest
docker stop filerise && docker rm filerise
# re-run with the same -v and -e flags you used originally
```
---
## Quick-start: Mount via WebDAV ## Quick-start: Mount via WebDAV
Once FileRise is running, enable WebDAV in the admin panel. Once FileRise is running, enable WebDAV in the admin panel.
@@ -283,27 +324,53 @@ https://your-host/webdav.php/
- Check **Connect using different credentials**, then enter your FileRise username/password. - Check **Connect using different credentials**, then enter your FileRise username/password.
- Click **Finish**. - 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. 📖 See the full [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV) for SSL setup, HTTP workaround, and troubleshooting.
--- ---
## Quick start: ONLYOFFICE (optional)
FileRise can open & edit office docs using your **self-hosted ONLYOFFICE Document Server**.
**What you need**
- A reachable ONLYOFFICE Document Server (Community/Enterprise).
- A shared **JWT secret** used by FileRise and your Document Server.
**Setup (23 minutes)**
1. In FileRise go to **Admin → ONLYOFFICE** and:
- ✅ Enable ONLYOFFICE
- 🔗 Set **Document Server Origin** (e.g., `https://docs.example.com`)
- 🔑 Enter **JWT Secret** (click “Replace” to set)
2. (Recommended) Click **Run tests** in the ONLYOFFICE card:
- Checks FileRise status, callback reachability, `api.js` load, and iframe embed.
3. Update your **Content-Security-Policy** to allow the DS origin.
The Admin panel shows a ready-to-copy line for Apache & Nginx. Example:
**Apache**
```apache
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com"
```
**Nginx**
```nginx
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com" always;
```
**Notes**
- If your site is https://, your Document Server must also be https:// (or the browser will block it as mixed content).
- Editor access respects FileRise ACLs (view/edit/share) exactly like the rest of the app.
---
## FAQ / Troubleshooting ## FAQ / Troubleshooting
- **ONLYOFFICE editor wont load / blank frame:** Verify CSP allows your DS origin (`script-src`, `frame-src`, `connect-src`) and that the DS is reachable over HTTPS if your site is HTTPS.
- **“Disabled — check JWT Secret / Origin” in tests:** In **Admin → ONLYOFFICE**, set the Document Server Origin and click “Replace” to save a JWT secret. Then re-run tests.
- **“Upload failed” or large files not uploading:** Ensure `TOTAL_UPLOAD_SIZE` in config and PHPs `post_max_size` / `upload_max_filesize` are set high enough. For extremely large files, you might need to increase `max_execution_time` or rely on resumable uploads in smaller chunks. - **“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. - **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.
@@ -320,6 +387,8 @@ For more Q&A or to ask for help, open a Discussion or Issue.
## Security posture ## Security posture
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If youre on ≤1.4.x, please upgrade.
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening). 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. 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. If youre running ≤1.4.x, please upgrade.
@@ -336,6 +405,17 @@ 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 ## 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). - **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).
@@ -348,6 +428,13 @@ If you like FileRise, a ⭐ star on GitHub is much appreciated!
## Dependencies ## Dependencies
### ONLYOFFICE integration
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**.
- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use.
Project page & license: <https://github.com/ONLYOFFICE/DocumentServer> (AGPL-3.0)
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
### PHP Libraries ### PHP Libraries
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0) - **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
@@ -369,10 +456,14 @@ If you like FileRise, a ⭐ star on GitHub is much appreciated!
## Acknowledgments ## Acknowledgments
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston. - [uploader](https://github.com/sensboston/uploader) by @sensboston.
--- ---
## License ## License & Credits
MIT License see [LICENSE](LICENSE). 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.

47
THIRD_PARTY.md Normal file
View File

@@ -0,0 +1,47 @@
# Third-Party Notices
FileRise bundles the following thirdparty assets. Each item lists the project, version, typical on-disk location in this repo, and its license.
If you believe any attribution is missing or incorrect, please open an issue.
---
## Fonts
- **Roboto (wght 400/500)** — Google Fonts
**License:** Apache License 2.0
**Files:** `public/css/vendor/roboto.css`, `public/fonts/roboto/*.woff2`
- **Material Icons (ligature font)** — Google Fonts
**License:** Apache License 2.0
**Files:** `public/css/vendor/material-icons.css`, `public/fonts/material-icons/*.woff2`
> Google fonts/icons © Google. Licensed under Apache 2.0. See `licenses/apache-2.0.txt`.
---
## CSS / JS Libraries (vendored)
- **Bootstrap 4.5.2** — MIT License
**Files:** `public/vendor/bootstrap/4.5.2/bootstrap.min.css`
- **CodeMirror 5.65.5** — MIT License
**Files:** `public/vendor/codemirror/5.65.5/*`
- **DOMPurify 2.4.0** — Apache License 2.0
**Files:** `public/vendor/dompurify/2.4.0/purify.min.js`
- **Fuse.js 6.6.2** — Apache License 2.0
**Files:** `public/vendor/fuse/6.6.2/fuse.min.js`
- **Resumable.js 1.1.0** — MIT License
**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`.
> 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)

View File

@@ -0,0 +1,5 @@
Google Fonts & Icons NOTICE
This product bundles font files from Google Fonts (Roboto, Material Icons, and/or Material Symbols).
Copyright 2012present Google Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (see ../apache-2.0.txt).

202
licenses/apache-2.0.txt Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

19
licenses/mit.txt Normal file
View File

@@ -0,0 +1,19 @@
MIT License
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.

View File

@@ -1,75 +1,128 @@
# ----------------------------- # --------------------------------
# 1) Prevent directory listings # FileRise portable .htaccess
# ----------------------------- # --------------------------------
Options -Indexes Options -Indexes -Multiviews
# -----------------------------
# Default index files
# -----------------------------
DirectoryIndex index.html DirectoryIndex index.html
# ----------------------------- # Allow PATH_INFO for routes like /webdav.php/foo/bar
# Deny access to hidden files AcceptPathInfo On
# -----------------------------
<FilesMatch "^\.">
Require all denied
</FilesMatch>
# ----------------------------- # ---------------- Security: dotfiles ----------------
# Enforce HTTPS (optional) <IfModule mod_authz_core.c>
# ----------------------------- # Block direct access to dotfiles like .env, .gitignore, etc.
<FilesMatch "^\..*">
Require all denied
</FilesMatch>
</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]
<IfModule mod_headers.c> # B) Behind reverse proxy that sets X-Forwarded-Proto
# Allow requests from a specific origin #RewriteCond %{HTTP:X-Forwarded-Proto} =http [OR]
#Header set Access-Control-Allow-Origin "https://demo.filerise.net" #RewriteCond %{HTTP:X-Forwarded-Proto} ^$
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS" #RewriteCond %{HTTPS} !=on
Header set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With, X-CSRF-Token" #RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
Header set Access-Control-Allow-Credentials "true"
# 5) Mark versioned assets (?v=...) with env flag for caching rules below
RewriteCond %{QUERY_STRING} (^|&)v= [NC]
RewriteRule ^ - [E=IS_VER:1]
</IfModule> </IfModule>
# ---------------- MIME types ----------------
<IfModule mod_mime.c>
AddType font/woff2 .woff2
AddType font/woff .woff
AddType image/svg+xml .svg
AddType application/javascript .mjs
</IfModule>
# ---------------- Security headers ----------------
<IfModule mod_headers.c> <IfModule mod_headers.c>
# Prevent clickjacking
Header always set X-Frame-Options "SAMEORIGIN" Header always set X-Frame-Options "SAMEORIGIN"
# Block XSS
Header always set X-XSS-Protection "1; mode=block" Header always set X-XSS-Protection "1; mode=block"
# No MIME sniffing
Header always set X-Content-Type-Options "nosniff" Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
Header always set X-Download-Options "noopen"
Header always set Expect-CT "max-age=86400, enforce"
Header always set Cross-Origin-Resource-Policy "same-origin"
Header always set X-Permitted-Cross-Domain-Policies "none"
# HSTS only when 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 ----------------
<IfModule mod_headers.c> <IfModule mod_headers.c>
# HTML: always revalidate # HTML/PHP: no cache
<FilesMatch "\.(html|htm)$"> <FilesMatch "\.(html?|php)$">
Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
Header setifempty Pragma "no-cache"
Header setifempty Expires "0"
</FilesMatch>
# version.js: never cache
<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>
# JS/CSS: shortterm cache, revalidate regularly
<FilesMatch "\.(js|css)$"> # JS/CSS: long cache if ?v= present, else 1h
Header set Cache-Control "public, max-age=3600, must-revalidate" <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>
# Images/fonts: long cache if ?v= present, else 7d
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
Header set Cache-Control "public, max-age=604800" env=!IS_VER
</FilesMatch> </FilesMatch>
</IfModule> </IfModule>
# ----------------------------- # ---------------- Compression ----------------
# Additional Security Headers <IfModule mod_brotli.c>
# ----------------------------- AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
<IfModule mod_headers.c> </IfModule>
# Enforce HTTPS for a year with subdomains and preload option. <IfModule mod_deflate.c>
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
# Set a Referrer Policy.
Header always set Referrer-Policy "strict-origin-when-cross-origin"
# Permissions Policy: disable features you don't need.
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
# IE-specific header to prevent downloads from opening in IE.
Header always set X-Download-Options "noopen"
# Expect-CT header for Certificate Transparency (optional).
Header always set Expect-CT "max-age=86400, enforce"
</IfModule> </IfModule>
# ----------------------------- # ---------------- Disable TRACE ----------------
# Disable TRACE method <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

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

@@ -153,7 +153,6 @@ if ($folder !== 'root') {
$perms = loadPermsFor($username); $perms = loadPermsFor($username);
$isAdmin = ACL::isAdmin($perms); $isAdmin = ACL::isAdmin($perms);
$readOnly = !empty($perms['readOnly']); $readOnly = !empty($perms['readOnly']);
$disableUp = !empty($perms['disableUpload']);
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin); $inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
// --- ACL base abilities --- // --- ACL base abilities ---
@@ -178,7 +177,7 @@ $gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
// --- Apply scope + flags to effective UI actions --- // --- Apply scope + flags to effective UI actions ---
$canView = $canViewBase && $inScope; // keep scope for folder-only $canView = $canViewBase && $inScope; // keep scope for folder-only
$canUpload = $gUploadBase && !$readOnly && !$disableUpload && $inScope; $canUpload = $gUploadBase && !$readOnly && $inScope;
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder** $canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder** $canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
$canDelete = $gDeleteBase && !$readOnly && $inScope; $canDelete = $gDeleteBase && !$readOnly && $inScope;
@@ -186,6 +185,7 @@ $canDelete = $gDeleteBase && !$readOnly && $inScope;
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope; $canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop) // Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
$canMoveIn = $canReceive; $canMoveIn = $canReceive;
$canMoveAlias = $canMoveIn;
$canEdit = $gEditBase && !$readOnly && $inScope; $canEdit = $gEditBase && !$readOnly && $inScope;
$canCopy = $gCopyBase && !$readOnly && $inScope; $canCopy = $gCopyBase && !$readOnly && $inScope;
$canExtract = $gExtractBase && !$readOnly && $inScope; $canExtract = $gExtractBase && !$readOnly && $inScope;
@@ -201,6 +201,12 @@ if ($isRoot) {
$canRename = false; $canRename = false;
$canDelete = false; $canDelete = false;
$canShareFoldEff = false; $canShareFoldEff = false;
$canMoveFolder = false;
}
if (!$isRoot) {
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
&& !$readOnly;
} }
$owner = null; $owner = null;
@@ -213,7 +219,6 @@ echo json_encode([
'flags' => [ 'flags' => [
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']), //'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
'readOnly' => $readOnly, 'readOnly' => $readOnly,
'disableUpload' => $disableUp,
], ],
'owner' => $owner, 'owner' => $owner,
@@ -227,6 +232,8 @@ echo json_encode([
'canRename' => $canRename, 'canRename' => $canRename,
'canDelete' => $canDelete, 'canDelete' => $canDelete,
'canMoveIn' => $canMoveIn, 'canMoveIn' => $canMoveIn,
'canMove' => $canMoveAlias,
'canMoveFolder'=> $canMoveFolder,
'canEdit' => $canEdit, 'canEdit' => $canEdit,
'canCopy' => $canCopy, 'canCopy' => $canCopy,
'canExtract' => $canExtract, 'canExtract' => $canExtract,

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,30 @@
<?php
// public/api/folder/isEmpty.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
// Snapshot then release session lock so parallel requests dont block
$user = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
];
@session_write_close();
// Input
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/');
// Delegate to controller (model handles ACL + path safety)
$result = FolderController::stats($folder, $user, $perms);
// Always return a compact JSON object like before
echo json_encode([
'folders' => (int)($result['folders'] ?? 0),
'files' => (int)($result['files'] ?? 0),
]);

View File

@@ -0,0 +1,9 @@
<?php
// public/api/folder/moveFolder.php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$controller = new FolderController();
$controller->moveFolder();

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,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

24
public/css/vendor/material-icons.css vendored Normal file
View File

@@ -0,0 +1,24 @@
/* fallback */
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2?v={{APP_QVER}}') format('woff2');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}

44
public/css/vendor/roboto.css vendored Normal file
View File

@@ -0,0 +1,44 @@
/* Roboto Regular 400 — latin-ext */
@font-face{
font-family:'Roboto';
font-style:normal;
font-weight:400;
font-display:swap;
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;
}
/* Roboto Regular 400 — latin */
@font-face{
font-family:'Roboto';
font-style:normal;
font-weight:400;
font-display:swap;
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;
}
/* Roboto Medium 500 — latin-ext */
@font-face{
font-family:'Roboto';
font-style:normal;
font-weight:500;
font-display:swap;
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;
}
/* Roboto Medium 500 — latin */
@font-face{
font-family:'Roboto';
font-style:normal;
font-weight:500;
font-display:swap;
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;
}
/* sensible stack so Chinese falls back cleanly */
:root{
--ui-font: Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
}
body{ font-family: var(--ui-font); }

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -2,142 +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="">
<meta name="share-url" content="">
<style>
/* hide the app shell until JS says otherwise */
.main-wrapper {
display: none;
}
/* full-screen white overlay while we check auth */
#loadingOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-color, #fff);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
</style> </style>
<!-- Google Fonts and Material Icons --> <!-- Favicons (ordered: SVG -> PNGs -> ICO) -->
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" /> <link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" /> <link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
<!-- Bootstrap CSS --> <link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" <link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous"> <link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css"
integrity="sha384-zaeBlB/vwYsDRSlFajnDd7OydJ0cWk+c2OWybl3eSUf6hW2EbhlCsQPqKr3gkznT" crossorigin="anonymous"> <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.">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css" <meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="color-scheme" content="light dark">
integrity="sha384-eZTPTN0EvJdn23s24UDYJmUM2T7C2ZFa3qFLypeBruJv8mZeTusKUAO/j5zPAQ6l" crossorigin="anonymous"> <link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js" <link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma"
crossorigin="anonymous"></script> <!-- Critical CSS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/xml/xml.min.js" <link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
integrity="sha384-xPpkMo5nDgD98fIcuRVYhxkZV6/9Y4L8s3p0J5c4MxgJkyKJ8BJr+xfRkq7kn6Tw" <link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
crossorigin="anonymous"></script> <link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/css/css.min.js"
integrity="sha384-to8njsu2GAiXQnY/aLGzz0DIY/SFSeSDodtvSl869n2NmsBdHOTZNNqbEBPYh7Pa" <!-- Fonts (ok to keep as real preloads) -->
crossorigin="anonymous"></script> <link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/javascript/javascript.min.js" <link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
integrity="sha384-kmQrbJf09Uo1WRLMDVGoVG3nM6F48frIhcj7f3FDUjeRzsiHwyBWDjMUIttnIeAf"
crossorigin="anonymous"></script> <!-- Vendor & version (deferred) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/resumable.js/1.1.0/resumable.min.js" <script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}" defer></script>
integrity="sha384-EXTg7rRfdTPZWoKVCslusAAev2TYw76fm+Wox718iEtFQ+gdAdAc5Z/ndLHSo4mq" <script src="/js/version.js?v={{APP_QVER}}" defer></script>
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.0/purify.min.js" <!-- App entry -->
integrity="sha384-Tsl3d5pUAO7a13enIvSsL3O0/95nsthPJiPto5NtLuY8w3+LbZOpr3Fl2MNmrh1E" <link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js"
integrity="sha384-zPE55eyESN+FxCWGEnlNxGyAPJud6IZ6TtJmXb56OFRGhxZPN4akj9rjA3gw5Qqa"
crossorigin="anonymous"></script>
<link rel="stylesheet" href="css/styles.css" />
</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;">
@@ -172,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>
@@ -181,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>
@@ -199,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>
@@ -215,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 -->
@@ -286,9 +217,27 @@
</div> </div>
</div> </div>
</div> </div>
<button id="moveFolderBtn" class="btn btn-warning ml-2" data-i18n-title="move_folder">
<i class="material-icons">drive_file_move</i>
</button>
<!-- MOVE FOLDER MODAL (place near your other folder modals) -->
<div id="moveFolderModal" class="modal" style="display:none;">
<div class="modal-content">
<h4 data-i18n-key="move_folder_title">Move Folder</h4>
<p data-i18n-key="move_folder_message">Select a destination folder to move the current folder
into:</p>
<select id="moveFolderTarget" class="form-control modal-input"></select>
<div class="modal-footer" style="margin-top:15px; text-align:right;">
<button id="cancelMoveFolder" class="btn btn-secondary"
data-i18n-key="cancel">Cancel</button>
<button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move" data-default>Move</button>
</div>
</div>
</div>
<button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder"> <button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder">
<i class="material-icons">drive_file_rename_outline</i> <i class="material-icons">drive_file_rename_outline</i>
</button> </button>
<div id="renameFolderModal" class="modal"> <div id="renameFolderModal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h4 data-i18n-key="rename_folder_title">Rename Folder</h4> <h4 data-i18n-key="rename_folder_title">Rename Folder</h4>
@@ -299,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>
@@ -319,7 +271,7 @@
<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>
@@ -355,7 +307,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>
@@ -369,7 +321,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>
@@ -383,58 +335,41 @@
<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>
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled <button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
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" data-i18n-title="extract_zip" <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" 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" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span> <span data-i18n-key="create">Create</span>
<span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
</button> </button>
<ul <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;">
id="createMenu" <li id="createFileOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
class="dropdown-menu" <span data-i18n-key="create_file">Create file</span>
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: 1000;
min-width: 140px;
"
>
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file" style="padding:8px 12px; cursor:pointer;">
${t('create_file')}
</li> </li>
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder" style="padding:8px 12px; cursor:pointer;"> <li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
${t('create_folder')} <span data-i18n-key="create_folder">Create folder</span>
</li> </li>
<li id="uploadOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
<span data-i18n-key="upload">Upload file(s)</span>
</li>
</ul> </ul>
</div> </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">
<h4 data-i18n-key="create_new_file">Create New File</h4> <h4 data-i18n-key="create_new_file">Create New File</h4>
<input <input type="text" id="createFileNameInput" class="form-control" placeholder="Enter filename…"
type="text" data-i18n-placeholder="newfile_placeholder" />
id="createFileNameInput"
class="form-control"
placeholder="Enter filename…"
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>
@@ -446,7 +381,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>
@@ -484,14 +419,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"
@@ -500,7 +435,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;">
@@ -525,7 +460,7 @@
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>
@@ -550,7 +485,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>
@@ -558,12 +493,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>
<script type="module" src="js/main.js"></script> <!-- 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) {

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,42 +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/";
// 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
@@ -48,85 +65,401 @@ function normalizeModeName(modeOption) {
return name; return name;
} }
const MODE_SRI = { // ---- ONLYOFFICE integration -----------------------------------------------
"mode/xml/xml.min.js": "sha512-LarNmzVokUmcA7aUDtqZ6oTS+YXmUKzpGdm8DxC46A6AHu+PQiYCUlwEGWidjVYMo/QXZMFMIadZtrkfApYp/g==",
"mode/css/css.min.js": "sha512-oikhYLgIKf0zWtVTOXh101BWoSacgv4UTJHQOHU+iUQ1Dol3Xjz/o9Jh0U33MPoT/d4aQruvjNvcYxvkTQd0nA==",
"mode/javascript/javascript.min.js": "sha512-I6CdJdruzGtvDyvdO4YsiAq+pkWf2efgd1ZUSK2FnM/u2VuRASPC7GowWQrWyjxCZn6CT89s3ddGI+be0Ak9Fg==",
"mode/htmlmixed/htmlmixed.min.js": "sha512-HN6cn6mIWeFJFwRN9yetDAMSh+AK9myHF1X9GlSlKmThaat65342Yw8wL7ITuaJnPioG0SYG09gy0qd5+s777w==",
"mode/php/php.min.js": "sha512-jZGz5n9AVTuQGhKTL0QzOm6bxxIQjaSbins+vD3OIdI7mtnmYE6h/L+UBGIp/SssLggbkxRzp9XkQNA4AyjFBw==",
"mode/markdown/markdown.min.js": "sha512-DmMao0nRIbyDjbaHc8fNd3kxGsZj9PCU6Iu/CeidLQT9Py8nYVA5n0PqXYmvqNdU+lCiTHOM/4E7bM/G8BttJg==",
"mode/python/python.min.js": "sha512-2M0GdbU5OxkGYMhakED69bw0c1pW3Nb0PeF3+9d+SnwN1ryPx3wiDdNqK3gSM7KAU/pEV+2tFJFbMKjKAahOkQ==",
"mode/sql/sql.min.js": "sha512-u8r8NUnG9B9L2dDmsfvs9ohQ0SO/Z7MB8bkdLxV7fE0Q8bOeP7/qft1D4KyE8HhVrpH3ihSrRoDiMbYR1VQBWQ==",
"mode/shell/shell.min.js": "sha512-HoC6JXgjHHevWAYqww37Gfu2c1G7SxAOv42wOakjR8csbTUfTB7OhVzSJ95LL62nII0RCyImp+7nR9zGmJ1wRQ==",
"mode/yaml/yaml.min.js": "sha512-+aXDZ93WyextRiAZpsRuJyiAZ38ztttUyO/H3FZx4gOAOv4/k9C6Um1CvHVtaowHZ2h7kH0d+orWvdBLPVwb4g==",
"mode/properties/properties.min.js": "sha512-P4OaO+QWj1wPRsdkEHlrgkx+a7qp6nUC8rI6dS/0/HPjHtlEmYfiambxowYa/UfqTxyNUnwTyPt5U6l1GO76yw==",
"mode/clike/clike.min.js": "sha512-l8ZIWnQ3XHPRG3MQ8+hT1OffRSTrFwrph1j1oc1Fzc9UKVGef5XN9fdO0vm3nW0PRgQ9LJgck6ciG59m69rvfg=="
};
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
function loadScriptOnce(url) { // Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
return new Promise((resolve, reject) => { let __ooCaps = { enabled: false, exts: new Set(), fetched: false, docsOrigin: null };
const key = `cm:${url}`;
let s = document.querySelector(`script[data-key="${key}"]`); async function fetchOnlyOfficeCapsOnce() {
if (s) { if (__ooCaps.fetched) return __ooCaps;
if (s.dataset.loaded === "1") return resolve(); try {
s.addEventListener("load", () => resolve()); const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
s.addEventListener("error", () => reject(new Error(`Load failed: ${url}`))); if (r.ok) {
return; 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 */ }
__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.src = url;
s.async = true; s.async = true;
s.dataset.key = key; s.onload = () => { clearTimeout(timer); _loadedScripts.add(url); resolve(); };
s.onerror = () => { clearTimeout(timer); reject(new Error(`Load failed: ${url}`)); };
// 🔒 Add SRI if we have it
const relPath = url.replace(/^https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/codemirror\/5\.65\.5\//, "");
const sri = MODE_SRI[relPath];
if (sri) {
s.integrity = sri;
s.crossOrigin = "anonymous";
// (Optional) further tighten referrer behavior:
// s.referrerPolicy = "no-referrer";
}
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
s.addEventListener("error", () => reject(new Error(`Load failed: ${url}`)));
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_CDN + 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() : "";
@@ -186,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");
@@ -237,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>
`; `;
@@ -260,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;
@@ -289,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
@@ -334,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,11 +1,11 @@
// fileMenu.js // fileMenu.js
import { updateRowHighlight, showToast } from './domUtils.js'; import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}';
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js'; import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}';
import { previewFile } from './filePreview.js'; import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
import { editFile } from './fileEditor.js'; import { editFile } from './fileEditor.js?v={{APP_QVER}}';
import { canEditFile, fileData } from './fileListView.js'; import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js'; import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
import { t } from './i18n.js'; import { t } from './i18n.js?v={{APP_QVER}}';
export function showFileContextMenu(x, y, menuItems) { export function showFileContextMenu(x, y, menuItems) {
let menu = document.getElementById("fileContextMenu"); let menu = document.getElementById("fileContextMenu");
@@ -39,11 +39,11 @@ export function showFileContextMenu(x, y, menuItems) {
}); });
menu.appendChild(menuItem); menu.appendChild(menuItem);
}); });
menu.style.left = x + "px"; menu.style.left = x + "px";
menu.style.top = y + "px"; menu.style.top = y + "px";
menu.style.display = "block"; menu.style.display = "block";
const menuRect = menu.getBoundingClientRect(); const menuRect = menu.getBoundingClientRect();
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
if (menuRect.bottom > viewportHeight) { if (menuRect.bottom > viewportHeight) {
@@ -62,7 +62,7 @@ export function hideFileContextMenu() {
export function fileListContextMenuHandler(e) { export function fileListContextMenuHandler(e) {
e.preventDefault(); e.preventDefault();
let row = e.target.closest("tr"); let row = e.target.closest("tr");
if (row) { if (row) {
const checkbox = row.querySelector(".file-checkbox"); const checkbox = row.querySelector(".file-checkbox");
@@ -71,9 +71,9 @@ export function fileListContextMenuHandler(e) {
updateRowHighlight(checkbox); updateRowHighlight(checkbox);
} }
} }
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value); const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
let menuItems = [ let menuItems = [
{ label: t("create_file"), action: () => openCreateFileModal() }, { label: t("create_file"), action: () => openCreateFileModal() },
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } }, { label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
@@ -81,14 +81,14 @@ export function fileListContextMenuHandler(e) {
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } }, { label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
{ label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } } { label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } }
]; ];
if (selected.some(name => name.toLowerCase().endsWith(".zip"))) { if (selected.some(name => name.toLowerCase().endsWith(".zip"))) {
menuItems.push({ menuItems.push({
label: t("extract_zip"), label: t("extract_zip"),
action: () => { handleExtractZipSelected(new Event("click")); } action: () => { handleExtractZipSelected(new Event("click")); }
}); });
} }
if (selected.length > 1) { if (selected.length > 1) {
menuItems.push({ menuItems.push({
label: t("tag_selected"), label: t("tag_selected"),
@@ -100,36 +100,33 @@ export function fileListContextMenuHandler(e) {
} }
else if (selected.length === 1) { else if (selected.length === 1) {
const file = fileData.find(f => f.name === selected[0]); const file = fileData.find(f => f.name === selected[0]);
menuItems.push({ menuItems.push({
label: t("preview"), label: t("preview"),
action: () => { action: () => {
const folder = window.currentFolder || "root"; const folder = window.currentFolder || "root";
const folderPath = folder === "root" previewFile(buildPreviewUrl(folder, file.name), file.name);
? "uploads/"
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
previewFile(folderPath + encodeURIComponent(file.name) + "?t=" + new Date().getTime(), file.name);
} }
}); });
if (canEditFile(file.name)) { if (canEditFile(file.name)) {
menuItems.push({ menuItems.push({
label: t("edit"), label: t("edit"),
action: () => { editFile(selected[0], window.currentFolder); } action: () => { editFile(selected[0], window.currentFolder); }
}); });
} }
menuItems.push({ menuItems.push({
label: t("rename"), label: t("rename"),
action: () => { renameFile(selected[0], window.currentFolder); } action: () => { renameFile(selected[0], window.currentFolder); }
}); });
menuItems.push({ menuItems.push({
label: t("tag_file"), label: t("tag_file"),
action: () => { openTagModal(file); } action: () => { openTagModal(file); }
}); });
} }
showFileContextMenu(e.clientX, e.clientY, menuItems); showFileContextMenu(e.clientX, e.clientY, menuItems);
} }
@@ -140,7 +137,7 @@ export function bindFileListContextMenu() {
} }
} }
document.addEventListener("click", function(e) { document.addEventListener("click", function (e) {
const menu = document.getElementById("fileContextMenu"); const menu = document.getElementById("fileContextMenu");
if (menu && menu.style.display === "block") { if (menu && menu.style.display === "block") {
hideFileContextMenu(); hideFileContextMenu();
@@ -148,9 +145,9 @@ document.addEventListener("click", function(e) {
}); });
// Rebind context menu after file table render. // Rebind context menu after file table render.
(function() { (function () {
const originalRenderFileTable = window.renderFileTable; const originalRenderFileTable = window.renderFileTable;
window.renderFileTable = function(folder) { window.renderFileTable = function (folder) {
originalRenderFileTable(folder); originalRenderFileTable(folder);
bindFileListContextMenu(); bindFileListContextMenu();
}; };

View File

@@ -1,14 +1,19 @@
// filePreview.js // filePreview.js
import { escapeHTML, showToast } from './domUtils.js'; import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
import { fileData } from './fileListView.js'; import { t } from './i18n.js?v={{APP_QVER}}';
import { t } from './i18n.js'; import { fileData, setFileProgressBadge, setFileWatchedBadge } from './fileListView.js?v={{APP_QVER}}';
// Build a preview URL that always goes through the API layer (respects ACLs/UPLOAD_DIR)
export function buildPreviewUrl(folder, name) {
const f = (!folder || folder === '') ? 'root' : String(folder);
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
}
/* -------------------------------- Share modal (existing) -------------------------------- */
export function openShareModal(file, folder) { export function openShareModal(file, folder) {
// Remove any existing modal
const existing = document.getElementById("shareModal"); const existing = document.getElementById("shareModal");
if (existing) existing.remove(); if (existing) existing.remove();
// Build the modal
const modal = document.createElement("div"); const modal = document.createElement("div");
modal.id = "shareModal"; modal.id = "shareModal";
modal.classList.add("modal"); modal.classList.add("modal");
@@ -45,18 +50,9 @@ export function openShareModal(file, folder) {
</div> </div>
<p style="margin-top:15px;">${t("password_optional")}</p> <p style="margin-top:15px;">${t("password_optional")}</p>
<input <input type="text" id="sharePassword" placeholder="${t("password_optional")}" style="width:100%;padding:5px;"/>
type="text"
id="sharePassword"
placeholder="${t("password_optional")}"
style="width:100%;padding:5px;"
/>
<button <button id="generateShareLinkBtn" class="btn btn-primary" style="margin-top:15px;">
id="generateShareLinkBtn"
class="btn btn-primary"
style="margin-top:15px;"
>
${t("generate_share_link")} ${t("generate_share_link")}
</button> </button>
@@ -73,48 +69,32 @@ export function openShareModal(file, folder) {
document.body.appendChild(modal); document.body.appendChild(modal);
modal.style.display = "block"; modal.style.display = "block";
// Close handler document.getElementById("closeShareModal").addEventListener("click", () => modal.remove());
document.getElementById("closeShareModal") document.getElementById("shareExpiration").addEventListener("change", e => {
.addEventListener("click", () => modal.remove()); const container = document.getElementById("customExpirationContainer");
container.style.display = e.target.value === "custom" ? "block" : "none";
});
// Show/hide custom-duration inputs document.getElementById("generateShareLinkBtn").addEventListener("click", () => {
document.getElementById("shareExpiration") const sel = document.getElementById("shareExpiration");
.addEventListener("change", e => { let value, unit;
const container = document.getElementById("customExpirationContainer");
container.style.display = e.target.value === "custom" ? "block" : "none";
});
// Generate share link if (sel.value === "custom") {
document.getElementById("generateShareLinkBtn") value = parseInt(document.getElementById("customExpirationValue").value, 10);
.addEventListener("click", () => { unit = document.getElementById("customExpirationUnit").value;
const sel = document.getElementById("shareExpiration"); } else {
let value, unit; value = parseInt(sel.value, 10);
unit = "minutes";
}
if (sel.value === "custom") { const password = document.getElementById("sharePassword").value;
value = parseInt(document.getElementById("customExpirationValue").value, 10);
unit = document.getElementById("customExpirationUnit").value;
} else {
value = parseInt(sel.value, 10);
unit = "minutes";
}
const password = document.getElementById("sharePassword").value; fetch("/api/file/createShareLink.php", {
method: "POST",
fetch("/api/file/createShareLink.php", { credentials: "include",
method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
credentials: "include", body: JSON.stringify({ folder, file: file.name, expirationValue: value, expirationUnit: unit, password })
headers: { })
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({
folder,
file: file.name,
expirationValue: value,
expirationUnit: unit,
password
})
})
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if (data.token) { if (data.token) {
@@ -122,349 +102,562 @@ export function openShareModal(file, folder) {
document.getElementById("shareLinkInput").value = url; document.getElementById("shareLinkInput").value = url;
document.getElementById("shareLinkDisplay").style.display = "block"; document.getElementById("shareLinkDisplay").style.display = "block";
} else { } else {
showToast(t("error_generating_share") + ": " + (data.error||"Unknown")); showToast(t("error_generating_share") + ": " + (data.error || "Unknown"));
} }
}) })
.catch(err => { .catch(err => {
console.error(err); console.error(err);
showToast(t("error_generating_share")); showToast(t("error_generating_share"));
}); });
}); });
// Copy to clipboard document.getElementById("copyShareLinkBtn").addEventListener("click", () => {
document.getElementById("copyShareLinkBtn") const input = document.getElementById("shareLinkInput");
.addEventListener("click", () => { input.select();
const input = document.getElementById("shareLinkInput"); document.execCommand("copy");
input.select(); showToast(t("link_copied"));
document.execCommand("copy"); });
showToast(t("link_copied"));
});
} }
export function previewFile(fileUrl, fileName) { /* -------------------------------- Media modal viewer -------------------------------- */
let modal = document.getElementById("filePreviewModal"); const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
if (!modal) { const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
modal = document.createElement("div"); const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
modal.id = "filePreviewModal"; const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
Object.assign(modal.style, { const CODE_RE = /\.(js|mjs|ts|tsx|json|yml|yaml|xml|html?|css|scss|less|php|py|rb|go|rs|c|cpp|h|hpp|java|cs|sh|bat|ps1)$/i;
position: "fixed", const TXT_RE = /\.(txt|rtf|md|log)$/i;
top: "0",
left: "0",
width: "100vw",
height: "100vh",
backgroundColor: "rgba(0,0,0,0.7)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: "1000"
});
modal.innerHTML = `
<div class="modal-content image-preview-modal-content" style="position: relative; max-width: 90vw; max-height: 90vh;">
<span id="closeFileModal" class="close-image-modal" style="position: absolute; top: 10px; right: 10px; font-size: 24px; cursor: pointer;">&times;</span>
<h4 class="image-modal-header"></h4>
<div class="file-preview-container" style="position: relative; text-align: center;"></div>
</div>`;
document.body.appendChild(modal);
function closeModal() { function getIconForFile(name) {
const mediaElements = modal.querySelectorAll("video, audio"); const lower = (name || '').toLowerCase();
mediaElements.forEach(media => { if (IMG_RE.test(lower)) return 'image';
media.pause(); if (VID_RE.test(lower)) return 'ondemand_video';
if (media.tagName.toLowerCase() !== 'video') { if (AUD_RE.test(lower)) return 'audiotrack';
try { media.currentTime = 0; } catch (e) { } if (lower.endsWith('.pdf')) return 'picture_as_pdf';
} if (ARCH_RE.test(lower)) return 'archive';
}); if (CODE_RE.test(lower)) return 'code';
modal.remove(); if (TXT_RE.test(lower)) return 'description';
} return 'insert_drive_file';
}
document.getElementById("closeFileModal").addEventListener("click", closeModal); function ensureMediaModal() {
modal.addEventListener("click", function (e) { let overlay = document.getElementById("filePreviewModal");
if (e.target === modal) { if (overlay) return overlay;
closeModal();
} overlay = document.createElement("div");
}); overlay.id = "filePreviewModal";
Object.assign(overlay.style, {
position: "fixed",
inset: "0",
width: "100vw",
height: "100vh",
backgroundColor: "rgba(0,0,0,0.7)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: "1000"
});
const root = document.documentElement;
const styles = getComputedStyle(root);
const isDark = root.classList.contains('dark-mode');
const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#121212' : '#ffffff');
const textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111');
const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)';
const navFg = '#fff';
const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)';
// fixed top bar; pad-right to avoid overlap with absolute close “×”
overlay.innerHTML = `
<div class="modal-content media-modal" style="
position: relative;
max-width: 92vw;
width: 92vw;
max-height: 92vh;
height: 92vh;
box-sizing: border-box;
background: ${panelBg};
color: ${textCol};
overflow: hidden;
border-radius: 10px;
display:flex; flex-direction:column;
">
<!-- Top bar -->
<div class="media-topbar" style="
flex:0 0 auto; display:flex; align-items:center; justify-content:space-between;
height:44px; padding:6px 12px; padding-right:56px; gap:10px;
border-bottom:1px solid ${isDark ? 'rgba(255,255,255,.12)' : 'rgba(0,0,0,.08)'};
background:${panelBg};
">
<div class="media-title" style="display:flex; align-items:center; gap:8px; min-width:0;">
<span class="material-icons title-icon" style="
width:22px; height:22px; display:inline-flex; align-items:center; justify-content:center;
font-size:22px; line-height:1; opacity:${isDark ? '0.96' : '0.9'};">
insert_drive_file
</span>
<div class="title-text" style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
</div>
<div class="media-right" style="display:flex; align-items:center; gap:8px;">
<span class="status-chip" style="
display:none; padding:4px 8px; border-radius:999px; font-size:12px; line-height:1;
border:1px solid transparent; background:transparent; color:inherit;"></span>
<div class="action-group" style="display:flex; gap:8px; align-items:center;"></div>
</div>
</div>
<!-- Stage -->
<div class="media-stage" style="position:relative; flex:1 1 auto; display:flex; align-items:center; justify-content:center; overflow:hidden;">
<div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div>
<!-- prev/next = rounded rectangles with centered glyphs -->
<button class="nav-left" aria-label="${t('previous')||'Previous'}" style="
position:absolute; left:8px; top:50%; transform:translateY(-50%);
height:56px; min-width:48px; padding:0 14px;
display:flex; align-items:center; justify-content:center;
font-size:38px; line-height:0;
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
text-shadow: 0 1px 2px rgba(0,0,0,.6);
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
box-shadow: 0 2px 8px rgba(0,0,0,.35);"></button>
<button class="nav-right" aria-label="${t('next')||'Next'}" style="
position:absolute; right:8px; top:50%; transform:translateY(-50%);
height:56px; min-width:48px; padding:0 14px;
display:flex; align-items:center; justify-content:center;
font-size:38px; line-height:0;
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
text-shadow: 0 1px 2px rgba(0,0,0,.6);
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
box-shadow: 0 2px 8px rgba(0,0,0,.35);"></button>
</div>
<!-- Absolute close “×” (like original), themed + hover behavior -->
<span id="closeFileModal" class="close-image-modal" title="${t('close')}" style="
position:absolute; top:8px; right:10px; z-index:1002;
width:32px; height:32px; display:inline-flex; align-items:center; justify-content:center;
font-size:22px; cursor:pointer; user-select:none; border-radius:50%; transition:all .15s ease;
">&times;</span>
</div>`;
document.body.appendChild(overlay);
// theme the close “×” for visibility + hover rules that match your site:
const closeBtn = overlay.querySelector("#closeFileModal");
function paintCloseBase() {
closeBtn.style.backgroundColor = 'transparent';
closeBtn.style.color = '#e11d48'; // base red X
closeBtn.style.boxShadow = 'none';
} }
modal.querySelector("h4").textContent = fileName; function onCloseHoverEnter() {
const container = modal.querySelector(".file-preview-container"); const dark = document.documentElement.classList.contains('dark-mode');
container.innerHTML = ""; closeBtn.style.backgroundColor = '#ef4444'; // red fill
closeBtn.style.color = dark ? '#000' : '#fff'; // X: black in dark / white in light
closeBtn.style.boxShadow = '0 0 6px rgba(239,68,68,.6)';
}
function onCloseHoverLeave() { paintCloseBase(); }
paintCloseBase();
closeBtn.addEventListener('mouseenter', onCloseHoverEnter);
closeBtn.addEventListener('mouseleave', onCloseHoverLeave);
const extension = fileName.split('.').pop().toLowerCase(); function closeModal() {
const isImage = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(fileName); try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {}
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
overlay.remove();
}
closeBtn.addEventListener("click", closeModal);
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
return overlay;
}
function setTitle(overlay, name) {
const textEl = overlay.querySelector('.title-text');
const iconEl = overlay.querySelector('.title-icon');
if (textEl) {
textEl.textContent = name || '';
textEl.setAttribute('title', name || '');
}
if (iconEl) {
iconEl.textContent = getIconForFile(name);
// keep the icon legible in both themes
const dark = document.documentElement.classList.contains('dark-mode');
iconEl.style.color = dark ? '#f5f5f5' : '#111111';
iconEl.style.opacity = dark ? '0.96' : '0.9';
}
}
// Topbar icon (theme-aware) used for image tools + video actions
function makeTopIcon(name, title) {
const b = document.createElement('button');
b.className = 'material-icons';
b.textContent = name;
b.title = title;
const dark = document.documentElement.classList.contains('dark-mode');
Object.assign(b.style, {
width: '32px',
height: '32px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: dark ? '1px solid rgba(255,255,255,.25)' : '1px solid rgba(0,0,0,.15)',
background: dark ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)',
cursor: 'pointer',
fontSize: '20px',
lineHeight: '1',
color: dark ? '#f5f5f5' : '#111',
boxShadow: dark ? '0 1px 2px rgba(0,0,0,.6)' : '0 1px 1px rgba(0,0,0,.08)'
});
b.addEventListener('mouseenter', () => {
const darkNow = document.documentElement.classList.contains('dark-mode');
b.style.background = darkNow ? 'rgba(255,255,255,.22)' : 'rgba(0,0,0,.14)';
});
b.addEventListener('mouseleave', () => {
const darkNow = document.documentElement.classList.contains('dark-mode');
b.style.background = darkNow ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)';
});
return b;
}
function setNavVisibility(overlay, showPrev, showNext) {
const prev = overlay.querySelector('.nav-left');
const next = overlay.querySelector('.nav-right');
prev.style.display = showPrev ? 'flex' : 'none';
next.style.display = showNext ? 'flex' : 'none';
}
function setRowWatchedBadge(name, watched) {
try {
const cell = document.querySelector(`tr[data-file-name="${CSS.escape(name)}"] .name-cell`);
if (!cell) return;
const old = cell.querySelector('.status-badge.watched');
if (watched) {
if (!old) {
const b = document.createElement('span');
b.className = 'status-badge watched';
b.textContent = t("watched") || t("viewed") || "Watched";
b.style.marginLeft = "6px";
cell.appendChild(b);
}
} else if (old) {
old.remove();
}
} catch {}
}
/* -------------------------------- Entry -------------------------------- */
export function previewFile(fileUrl, fileName) {
const overlay = ensureMediaModal();
const container = overlay.querySelector(".file-preview-container");
const actionWrap = overlay.querySelector(".media-right .action-group");
const statusChip = overlay.querySelector(".media-right .status-chip");
// replace nav buttons to clear old listeners
let prevBtn = overlay.querySelector('.nav-left');
let nextBtn = overlay.querySelector('.nav-right');
const newPrev = prevBtn.cloneNode(true);
const newNext = nextBtn.cloneNode(true);
prevBtn.replaceWith(newPrev);
nextBtn.replaceWith(newNext);
prevBtn = newPrev; nextBtn = newNext;
// reset
container.innerHTML = "";
actionWrap.innerHTML = "";
if (statusChip) statusChip.style.display = 'none';
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
overlay._onKey = null;
const folder = window.currentFolder || 'root';
const name = fileName;
const lower = (name || '').toLowerCase();
const isImage = IMG_RE.test(lower);
const isVideo = VID_RE.test(lower);
const isAudio = AUD_RE.test(lower);
setTitle(overlay, name);
/* -------------------- IMAGES -------------------- */
if (isImage) { if (isImage) {
// Create the image element with default transform data.
const img = document.createElement("img"); const img = document.createElement("img");
img.src = fileUrl; img.src = fileUrl;
img.className = "image-modal-img"; img.className = "image-modal-img";
img.style.maxWidth = "80vw"; img.style.maxWidth = "88vw";
img.style.maxHeight = "80vh"; img.style.maxHeight = "88vh";
img.style.transition = "transform 0.3s ease"; img.style.transition = "transform 0.3s ease";
img.dataset.scale = 1; img.dataset.scale = 1;
img.dataset.rotate = 0; img.dataset.rotate = 0;
img.style.position = 'relative'; container.appendChild(img);
img.style.zIndex = '1';
// Filter gallery images for navigation. // topbar-aligned, theme-aware icons
const images = fileData.filter(file => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)); const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
actionWrap.appendChild(zoomInBtn);
actionWrap.appendChild(zoomOutBtn);
actionWrap.appendChild(rotateLeft);
actionWrap.appendChild(rotateRight);
// Create a flex wrapper to hold left panel, center image, and right panel. zoomInBtn.addEventListener('click', (e) => {
const wrapper = document.createElement('div'); e.stopPropagation();
wrapper.className = 'image-wrapper'; let s = parseFloat(img.dataset.scale) || 1; s += 0.1;
wrapper.style.display = 'flex'; img.dataset.scale = s;
wrapper.style.alignItems = 'center'; img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`;
wrapper.style.justifyContent = 'center'; });
wrapper.style.position = 'relative'; zoomOutBtn.addEventListener('click', (e) => {
e.stopPropagation();
let s = parseFloat(img.dataset.scale) || 1; s = Math.max(0.1, s - 0.1);
img.dataset.scale = s;
img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`;
});
rotateLeft.addEventListener('click', (e) => {
e.stopPropagation();
let r = parseFloat(img.dataset.rotate) || 0; r = (r - 90 + 360) % 360;
img.dataset.rotate = r;
img.style.transform = `scale(${img.dataset.scale}) rotate(${r}deg)`;
});
rotateRight.addEventListener('click', (e) => {
e.stopPropagation();
let r = parseFloat(img.dataset.rotate) || 0; r = (r + 90) % 360;
img.dataset.rotate = r;
img.style.transform = `scale(${img.dataset.scale}) rotate(${r}deg)`;
});
// --- Left Panel: Contains Zoom controls (top) and Prev button (bottom) --- const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name));
const leftPanel = document.createElement('div'); overlay.mediaType = 'image';
leftPanel.className = 'left-panel'; overlay.mediaList = images;
leftPanel.style.display = 'flex'; overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
leftPanel.style.flexDirection = 'column'; setNavVisibility(overlay, images.length > 1, images.length > 1);
leftPanel.style.justifyContent = 'space-between';
leftPanel.style.alignItems = 'center';
leftPanel.style.width = '60px';
leftPanel.style.height = '100%';
leftPanel.style.zIndex = '10';
// Top container for zoom buttons. const navigate = (dir) => {
const leftTop = document.createElement('div'); if (!overlay.mediaList || overlay.mediaList.length < 2) return;
leftTop.style.display = 'flex'; overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
leftTop.style.flexDirection = 'column'; const newFile = overlay.mediaList[overlay.mediaIndex].name;
leftTop.style.gap = '4px'; setTitle(overlay, newFile);
// Zoom In button. img.dataset.scale = 1;
const zoomInBtn = document.createElement('button'); img.dataset.rotate = 0;
zoomInBtn.className = 'material-icons zoom_in'; img.style.transform = 'scale(1) rotate(0deg)';
zoomInBtn.title = 'Zoom In'; img.src = buildPreviewUrl(folder, newFile);
zoomInBtn.style.background = 'transparent'; };
zoomInBtn.style.border = 'none';
zoomInBtn.style.cursor = 'pointer';
zoomInBtn.textContent = 'zoom_in';
// Zoom Out button.
const zoomOutBtn = document.createElement('button');
zoomOutBtn.className = 'material-icons zoom_out';
zoomOutBtn.title = 'Zoom Out';
zoomOutBtn.style.background = 'transparent';
zoomOutBtn.style.border = 'none';
zoomOutBtn.style.cursor = 'pointer';
zoomOutBtn.textContent = 'zoom_out';
leftTop.appendChild(zoomInBtn);
leftTop.appendChild(zoomOutBtn);
leftPanel.appendChild(leftTop);
// Bottom container for prev button.
const leftBottom = document.createElement('div');
leftBottom.style.display = 'flex';
leftBottom.style.justifyContent = 'center';
leftBottom.style.alignItems = 'center';
leftBottom.style.width = '100%';
if (images.length > 1) { if (images.length > 1) {
const prevBtn = document.createElement("button"); prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
prevBtn.textContent = ""; nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
prevBtn.className = "gallery-nav-btn"; const onKey = (e) => {
prevBtn.style.background = 'transparent'; if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
prevBtn.style.border = 'none'; if (e.key === "ArrowLeft") navigate(-1);
prevBtn.style.color = 'white'; if (e.key === "ArrowRight") navigate(+1);
prevBtn.style.fontSize = '48px'; };
prevBtn.style.cursor = 'pointer'; window.addEventListener("keydown", onKey);
prevBtn.addEventListener("click", function (e) { overlay._onKey = onKey;
e.stopPropagation();
// Safety check:
if (!modal.galleryImages || modal.galleryImages.length === 0) return;
modal.galleryCurrentIndex = (modal.galleryCurrentIndex - 1 + modal.galleryImages.length) % modal.galleryImages.length;
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
modal.querySelector("h4").textContent = newFile.name;
img.src = ((window.currentFolder === "root")
? "uploads/"
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
// Reset transforms.
img.dataset.scale = 1;
img.dataset.rotate = 0;
img.style.transform = 'scale(1) rotate(0deg)';
});
leftBottom.appendChild(prevBtn);
} else {
// Insert an empty placeholder for consistent layout.
leftBottom.innerHTML = '&nbsp;';
} }
leftPanel.appendChild(leftBottom);
// --- Center Panel: Contains the image --- overlay.style.display = "flex";
const centerPanel = document.createElement('div'); return;
centerPanel.className = 'center-image-container'; }
centerPanel.style.flexGrow = '1';
centerPanel.style.textAlign = 'center';
centerPanel.style.position = 'relative';
centerPanel.style.zIndex = '1';
centerPanel.appendChild(img);
// --- Right Panel: Contains Rotate controls (top) and Next button (bottom) --- /* -------------------- PDF => new tab -------------------- */
const rightPanel = document.createElement('div'); if (lower.endsWith('.pdf')) {
rightPanel.className = 'right-panel';
rightPanel.style.display = 'flex';
rightPanel.style.flexDirection = 'column';
rightPanel.style.justifyContent = 'space-between';
rightPanel.style.alignItems = 'center';
rightPanel.style.width = '60px';
rightPanel.style.height = '100%';
rightPanel.style.zIndex = '10';
// Top container for rotate buttons.
const rightTop = document.createElement('div');
rightTop.style.display = 'flex';
rightTop.style.flexDirection = 'column';
rightTop.style.gap = '4px';
// Rotate Left button.
const rotateLeftBtn = document.createElement('button');
rotateLeftBtn.className = 'material-icons rotate_left';
rotateLeftBtn.title = 'Rotate Left';
rotateLeftBtn.style.background = 'transparent';
rotateLeftBtn.style.border = 'none';
rotateLeftBtn.style.cursor = 'pointer';
rotateLeftBtn.textContent = 'rotate_left';
// Rotate Right button.
const rotateRightBtn = document.createElement('button');
rotateRightBtn.className = 'material-icons rotate_right';
rotateRightBtn.title = 'Rotate Right';
rotateRightBtn.style.background = 'transparent';
rotateRightBtn.style.border = 'none';
rotateRightBtn.style.cursor = 'pointer';
rotateRightBtn.textContent = 'rotate_right';
rightTop.appendChild(rotateLeftBtn);
rightTop.appendChild(rotateRightBtn);
rightPanel.appendChild(rightTop);
// Bottom container for next button.
const rightBottom = document.createElement('div');
rightBottom.style.display = 'flex';
rightBottom.style.justifyContent = 'center';
rightBottom.style.alignItems = 'center';
rightBottom.style.width = '100%';
if (images.length > 1) {
const nextBtn = document.createElement("button");
nextBtn.textContent = "";
nextBtn.className = "gallery-nav-btn";
nextBtn.style.background = 'transparent';
nextBtn.style.border = 'none';
nextBtn.style.color = 'white';
nextBtn.style.fontSize = '48px';
nextBtn.style.cursor = 'pointer';
nextBtn.addEventListener("click", function (e) {
e.stopPropagation();
// Safety check:
if (!modal.galleryImages || modal.galleryImages.length === 0) return;
modal.galleryCurrentIndex = (modal.galleryCurrentIndex + 1) % modal.galleryImages.length;
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
modal.querySelector("h4").textContent = newFile.name;
img.src = ((window.currentFolder === "root")
? "uploads/"
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
// Reset transforms.
img.dataset.scale = 1;
img.dataset.rotate = 0;
img.style.transform = 'scale(1) rotate(0deg)';
});
rightBottom.appendChild(nextBtn);
} else {
// Insert a placeholder so that center remains properly aligned.
rightBottom.innerHTML = '&nbsp;';
}
rightPanel.appendChild(rightBottom);
// Assemble panels into the wrapper.
wrapper.appendChild(leftPanel);
wrapper.appendChild(centerPanel);
wrapper.appendChild(rightPanel);
container.appendChild(wrapper);
// --- Set up zoom controls event listeners ---
zoomInBtn.addEventListener('click', function (e) {
e.stopPropagation();
let scale = parseFloat(img.dataset.scale) || 1;
scale += 0.1;
img.dataset.scale = scale;
img.style.transform = 'scale(' + scale + ') rotate(' + img.dataset.rotate + 'deg)';
});
zoomOutBtn.addEventListener('click', function (e) {
e.stopPropagation();
let scale = parseFloat(img.dataset.scale) || 1;
scale = Math.max(0.1, scale - 0.1);
img.dataset.scale = scale;
img.style.transform = 'scale(' + scale + ') rotate(' + img.dataset.rotate + 'deg)';
});
// Attach rotation control listeners (always present now).
rotateLeftBtn.addEventListener('click', function (e) {
e.stopPropagation();
let rotate = parseFloat(img.dataset.rotate) || 0;
rotate = (rotate - 90 + 360) % 360;
img.dataset.rotate = rotate;
img.style.transform = 'scale(' + img.dataset.scale + ') rotate(' + rotate + 'deg)';
});
rotateRightBtn.addEventListener('click', function (e) {
e.stopPropagation();
let rotate = parseFloat(img.dataset.rotate) || 0;
rotate = (rotate + 90) % 360;
img.dataset.rotate = rotate;
img.style.transform = 'scale(' + img.dataset.scale + ') rotate(' + rotate + 'deg)';
});
// Save gallery details if there is more than one image.
if (images.length > 1) {
modal.galleryImages = images;
modal.galleryCurrentIndex = images.findIndex(f => f.name === fileName);
}
} else {
// Handle non-image file previews.
if (extension === "pdf") {
// build a cachebusted URL
const separator = fileUrl.includes('?') ? '&' : '?'; const separator = fileUrl.includes('?') ? '&' : '?';
const urlWithTs = fileUrl + separator + 't=' + Date.now(); const urlWithTs = fileUrl + separator + 't=' + Date.now();
// open in a new tab (avoids CSP frame-ancestors)
window.open(urlWithTs, "_blank"); window.open(urlWithTs, "_blank");
overlay.remove();
// tear down the just-created modal
const modal = document.getElementById("filePreviewModal");
if (modal) modal.remove();
// stop further preview logic
return; return;
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) { }
const video = document.createElement("video");
video.src = fileUrl; /* -------------------- VIDEOS -------------------- */
video.controls = true; if (isVideo) {
video.className = "image-modal-img"; let video = document.createElement("video"); // let so we can rebind
video.controls = true;
const progressKey = 'videoProgress-' + fileUrl; video.style.maxWidth = "88vw";
video.addEventListener("loadedmetadata", () => { video.style.maxHeight = "88vh";
const savedTime = localStorage.getItem(progressKey); video.style.objectFit = "contain";
if (savedTime) { container.appendChild(video);
video.currentTime = parseFloat(savedTime);
// Top-right action icons (Material icons, theme-aware)
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
actionWrap.appendChild(markBtnIcon);
actionWrap.appendChild(clearBtnIcon);
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
overlay.mediaType = 'video';
overlay.mediaList = videos;
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); };
const SAVE_INTERVAL_MS = 5000;
let lastSaveAt = 0;
let pending = false;
async function getProgress(nm) {
try {
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
const data = await res.json();
return data && data.state ? data.state : null;
} catch { return null; }
}
async function sendProgress({nm, seconds, duration, completed, clear}) {
try {
pending = true;
const res = await fetch("/api/media/updateProgress.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
});
const data = await res.json();
pending = false;
return data;
} catch (e) { pending = false; console.error(e); return null; }
}
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
function renderStatus(state) {
if (!statusChip) return;
// Completed
if (state && state.completed) {
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
statusChip.style.background = 'rgba(34,197,94,.15)';
statusChip.style.color = '#22c55e';
markBtnIcon.style.display = 'none';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// In progress
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
statusChip.textContent = `${pct}%`;
statusChip.style.display = 'inline-block';
const dark = document.documentElement.classList.contains('dark-mode');
const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
statusChip.style.color = ORANGE_HEX;
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// No progress
statusChip.style.display = 'none';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = 'none';
}
function bindVideoEvents(nm) {
const nv = video.cloneNode(true);
video.replaceWith(nv);
video = nv;
video.addEventListener("loadedmetadata", async () => {
try {
const state = await getProgress(nm);
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
video.currentTime = state.seconds;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
setFileProgressBadge(nm, seconds, duration);
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
} else {
const ls = localStorage.getItem(lsKey(nm));
if (ls) video.currentTime = parseFloat(ls);
}
renderStatus(state || null);
} catch {
renderStatus(null);
} }
}); });
video.addEventListener("timeupdate", () => {
localStorage.setItem(progressKey, video.currentTime); video.addEventListener("timeupdate", async () => {
const now = Date.now();
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
lastSaveAt = now;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
sendProgress({ nm, seconds, duration });
setFileProgressBadge(nm, seconds, duration);
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
renderStatus({ seconds, duration, completed: false });
}); });
video.addEventListener("ended", () => {
localStorage.removeItem(progressKey); video.addEventListener("ended", async () => {
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
}); });
container.appendChild(video);
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(fileName)) { markBtnIcon.onclick = async () => {
const audio = document.createElement("audio"); const duration = Math.floor(video.duration || 0);
audio.src = fileUrl; await sendProgress({ nm, seconds: duration, duration, completed: true });
audio.controls = true; showToast(t("marked_viewed") || "Marked as viewed");
audio.className = "audio-modal"; setFileWatchedBadge(nm, true);
audio.style.maxWidth = "80vw"; renderStatus({ seconds: duration, duration, completed: true });
container.appendChild(audio); };
} else { clearBtnIcon.onclick = async () => {
container.textContent = "Preview not available for this file type."; await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("progress_cleared") || "Progress cleared");
setFileWatchedBadge(nm, false);
renderStatus(null);
};
} }
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const nm = overlay.mediaList[overlay.mediaIndex].name;
setVideoSrc(nm);
bindVideoEvents(nm);
};
if (videos.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
const onKey = (e) => {
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
if (e.key === "ArrowLeft") navigate(-1);
if (e.key === "ArrowRight") navigate(+1);
};
window.addEventListener("keydown", onKey);
overlay._onKey = onKey;
}
setVideoSrc(name);
renderStatus(null);
bindVideoEvents(name);
overlay.style.display = "flex";
return;
}
/* -------------------- AUDIO / OTHER -------------------- */
if (isAudio) {
const audio = document.createElement("audio");
audio.src = fileUrl;
audio.controls = true;
audio.className = "audio-modal";
audio.style.maxWidth = "88vw";
container.appendChild(audio);
overlay.style.display = "flex";
} else {
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
overlay.style.display = "flex";
} }
modal.style.display = "flex";
} }
// Preserve original functionality. /* -------------------------------- Small display helper -------------------------------- */
export function displayFilePreview(file, container) { export function displayFilePreview(file, container) {
const actualFile = file.file || file; const actualFile = file.file || file;
if (!(actualFile instanceof File)) { if (!(actualFile instanceof File)) {
@@ -472,10 +665,9 @@ export function displayFilePreview(file, container) {
return; return;
} }
container.style.display = "inline-block"; container.style.display = "inline-block";
while (container.firstChild) { while (container.firstChild) container.removeChild(container.firstChild);
container.removeChild(container.firstChild);
} if (IMG_RE.test(actualFile.name)) {
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(actualFile.name)) {
const img = document.createElement("img"); const img = document.createElement("img");
img.src = URL.createObjectURL(actualFile); img.src = URL.createObjectURL(actualFile);
img.classList.add("file-preview-img"); img.classList.add("file-preview-img");
@@ -488,5 +680,6 @@ export function displayFilePreview(file, container) {
} }
} }
// expose for HTML onclick usage
window.previewFile = previewFile; window.previewFile = previewFile;
window.openShareModal = openShareModal; window.openShareModal = openShareModal;

View File

@@ -3,9 +3,9 @@
// adding tags to files (with a global tag store for reuse), // adding tags to files (with a global tag store for reuse),
// updating the file row display with tag badges, // updating the file row display with tag badges,
// filtering the file list by tag, and persisting tag data. // filtering the file list by tag, and persisting tag data.
import { escapeHTML } from './domUtils.js'; import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js'; import { t } from './i18n.js?v={{APP_QVER}}';
import { renderFileTable, renderGalleryView } from './fileListView.js'; import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}';
export function openTagModal(file) { export function openTagModal(file) {
// Create the modal element. // Create the modal element.

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

@@ -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.",
@@ -282,7 +282,43 @@ const translations = {
"bypass_ownership": "Bypass Ownership", "bypass_ownership": "Bypass Ownership",
"error_loading_user_grants": "Error loading user grants", "error_loading_user_grants": "Error loading user grants",
"click_to_edit": "Click to edit", "click_to_edit": "Click to edit",
"folder_access": "Folder Access" "folder_access": "Folder Access",
"move_folder": "Move Folder",
"move_folder_message": "Select a destination folder to move this folder to:",
"move_folder_title": "Move this folder",
"move_folder_success": "Folder moved successfully.",
"move_folder_error": "Error moving folder.",
"move_folder_invalid": "Invalid source or destination folder.",
"move_folder_denied": "You do not have permission to move this folder.",
"move_folder_same_dest": "Destination cannot be the source or one of its subfolders.",
"move_folder_same_owner": "Source and destination must have the same owner.",
"move_folder_confirm": "Are you sure you want to move this folder?",
"move_folder_select_dest": "Select a destination folder",
"move_folder_select_dest_help": "Choose where this folder should be moved to.",
"acl_move_folder_label": "Move Folder (source)",
"acl_move_folder_help": "Allows moving this folder to a different parent. Requires Manage or Ownership on the folder.",
"acl_move_in_label": "Allow Moves Into This Folder (destination)",
"acl_move_in_help": "Allows items or folders from elsewhere to be moved into this folder. Requires Manage on the destination folder.",
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
"context_move_folder": "Move Folder...",
"context_move_here": "Move Here",
"context_move_cancel": "Cancel Move",
"mark_as_viewed": "Mark as viewed",
"viewed": "Viewed",
"resumed_from": "Resumed from",
"clear_progress": "Clear progress",
"marked_viewed": "Marked as viewed",
"progress_cleared": "Progress cleared",
"previous": "Previous",
"next": "Next",
"watched": "Watched",
"reset_progress": "Reset Progress",
"color_folder": "Color folder",
"choose_color": "Choose a color",
"reset_default": "Reset",
"save_color": "Save",
"folder_color_saved": "Folder color saved.",
"folder_color_cleared": "Folder color reset."
}, },
es: { 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,10 @@
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}}';
/* ----------------------------------------------------- /* -----------------------------------------------------
Helpers for DragandDrop Folder Uploads (Original Code) Helpers for DragandDrop Folder Uploads (Original Code)
@@ -36,6 +37,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 = [];
@@ -161,91 +194,91 @@ function createFileEntry(file) {
const removeBtn = document.createElement("button"); const removeBtn = document.createElement("button");
removeBtn.classList.add("remove-file-btn"); removeBtn.classList.add("remove-file-btn");
removeBtn.textContent = "×"; removeBtn.textContent = "×";
// In your remove button event listener, replace the fetch call with: // In your remove button event listener, replace the fetch call with:
removeBtn.addEventListener("click", function (e) { removeBtn.addEventListener("click", function (e) {
e.stopPropagation(); e.stopPropagation();
const uploadIndex = file.uploadIndex; const uploadIndex = file.uploadIndex;
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex); window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
// Cancel the file upload if possible. // Cancel the file upload if possible.
if (typeof file.cancel === "function") { if (typeof file.cancel === "function") {
file.cancel(); file.cancel();
console.log("Canceled file upload:", file.fileName); console.log("Canceled file upload:", file.fileName);
} }
// Remove file from the resumable queue. // Remove file from the resumable queue.
if (resumableInstance && typeof resumableInstance.removeFile === "function") { if (resumableInstance && typeof resumableInstance.removeFile === "function") {
resumableInstance.removeFile(file); resumableInstance.removeFile(file);
} }
// Call our helper repeatedly to remove the chunk folder. // Call our helper repeatedly to remove the chunk folder.
if (file.uniqueIdentifier) { if (file.uniqueIdentifier) {
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000); removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
} }
li.remove(); li.remove();
updateFileInfoCount(); updateFileInfoCount();
}); });
li.removeBtn = removeBtn; li.removeBtn = removeBtn;
li.appendChild(removeBtn); li.appendChild(removeBtn);
// Add pause/resume/restart button if the file supports pause/resume. // Add pause/resume/restart button if the file supports pause/resume.
// Conditionally add the pause/resume button only if file.pause is available // Conditionally add the pause/resume button only if file.pause is available
// Pause/Resume button (for resumable filepicker uploads) // Pause/Resume button (for resumable filepicker uploads)
if (typeof file.pause === "function") { if (typeof file.pause === "function") {
const pauseResumeBtn = document.createElement("button"); const pauseResumeBtn = document.createElement("button");
pauseResumeBtn.setAttribute("type", "button"); // not a submit button pauseResumeBtn.setAttribute("type", "button"); // not a submit button
pauseResumeBtn.classList.add("pause-resume-btn"); pauseResumeBtn.classList.add("pause-resume-btn");
// Start with pause icon and disable button until upload starts // Start with pause icon and disable button until upload starts
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>'; pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
pauseResumeBtn.disabled = true; pauseResumeBtn.disabled = true;
pauseResumeBtn.addEventListener("click", function (e) { pauseResumeBtn.addEventListener("click", function (e) {
e.stopPropagation(); e.stopPropagation();
if (file.isError) { if (file.isError) {
// If the file previously failed, try restarting upload. // If the file previously failed, try restarting upload.
if (typeof file.retry === "function") { if (typeof file.retry === "function") {
file.retry(); file.retry();
file.isError = false; file.isError = false;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>'; pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
} }
} else if (!file.paused) { } else if (!file.paused) {
// Pause the upload (if possible) // Pause the upload (if possible)
if (typeof file.pause === "function") {
file.pause();
file.paused = true;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
} else {
}
} else if (file.paused) {
// Resume sequence: first call to resume (or upload() fallback)
if (typeof file.resume === "function") {
file.resume();
} else {
resumableInstance.upload();
}
// After a short delay, pause again then resume
setTimeout(() => {
if (typeof file.pause === "function") { if (typeof file.pause === "function") {
file.pause(); file.pause();
file.paused = true;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
} else {
}
} else if (file.paused) {
// Resume sequence: first call to resume (or upload() fallback)
if (typeof file.resume === "function") {
file.resume();
} else { } else {
resumableInstance.upload(); resumableInstance.upload();
} }
// After a short delay, pause again then resume
setTimeout(() => { setTimeout(() => {
if (typeof file.resume === "function") { if (typeof file.pause === "function") {
file.resume(); file.pause();
} else { } else {
resumableInstance.upload(); resumableInstance.upload();
} }
setTimeout(() => {
if (typeof file.resume === "function") {
file.resume();
} else {
resumableInstance.upload();
}
}, 100);
}, 100); }, 100);
}, 100); file.paused = false;
file.paused = false; pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>'; } else {
} else { console.error("Pause/resume function not available for file", file);
console.error("Pause/resume function not available for file", file); }
} });
}); li.appendChild(pauseResumeBtn);
li.appendChild(pauseResumeBtn); }
}
// Preview element // Preview element
const preview = document.createElement("div"); const preview = document.createElement("div");
@@ -401,29 +434,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",
query: { folder: window.currentFolder || "root", upload_token: window.csrfToken }, // Make init async-safe; it resolves when Resumable is constructed
chunkSize: 1.5 * 1024 * 1024, // 1.5 MB chunks async function initResumableUpload() {
simultaneousUploads: 3, if (resumableInstance) return;
forceChunkSize: true, // Load the library if needed
testChunks: false, const ResumableCtor = await lazyLoadResumable().catch(err => {
throttleProgressCallbacks: 1, console.error('Failed to load Resumable.js:', err);
withCredentials: true, return null;
headers: { 'X-CSRF-Token': window.csrfToken },
query: {
folder: window.currentFolder || "root",
upload_token: window.csrfToken // still as a fallback
}
}); });
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: false,
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)
function updateResumableQuery() {
if (!resumableInstance) return;
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
resumableInstance.opts.query.folder = window.currentFolder || 'root';
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]);
@@ -432,6 +485,7 @@ function initResumableUpload() {
} }
resumableInstance.on("fileAdded", function (file) { resumableInstance.on("fileAdded", function (file) {
// Initialize custom paused flag // Initialize custom paused flag
file.paused = false; file.paused = false;
file.uploadIndex = file.uniqueIdentifier; file.uploadIndex = file.uniqueIdentifier;
@@ -461,16 +515,17 @@ function initResumableUpload() {
li.dataset.uploadIndex = file.uniqueIdentifier; li.dataset.uploadIndex = file.uniqueIdentifier;
list.appendChild(li); list.appendChild(li);
updateFileInfoCount(); updateFileInfoCount();
updateResumableQuery();
}); });
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); const percent = Math.floor(progress * 100);
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`); const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
if (li && li.progressBar) { if (li && li.progressBar) {
if (percent < 99) { if (percent < 99) {
li.progressBar.style.width = percent + "%"; li.progressBar.style.width = percent + "%";
// Calculate elapsed time and speed. // Calculate elapsed time and speed.
const elapsed = (Date.now() - li.startTime) / 1000; const elapsed = (Date.now() - li.startTime) / 1000;
let speed = ""; let speed = "";
@@ -491,7 +546,7 @@ function initResumableUpload() {
li.progressBar.style.width = "100%"; li.progressBar.style.width = "100%";
li.progressBar.innerHTML = '<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>'; li.progressBar.innerHTML = '<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
} }
// Enable the pause/resume button once progress starts. // Enable the pause/resume button once progress starts.
const pauseResumeBtn = li.querySelector(".pause-resume-btn"); const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) { if (pauseResumeBtn) {
@@ -499,8 +554,8 @@ function initResumableUpload() {
} }
} }
}); });
resumableInstance.on("fileSuccess", function(file, message) { resumableInstance.on("fileSuccess", function (file, message) {
// Try to parse JSON response // Try to parse JSON response
let data; let data;
try { try {
@@ -508,18 +563,18 @@ function initResumableUpload() {
} catch (e) { } catch (e) {
data = null; data = null;
} }
// 1) Softfail CSRF? then update token & retry this file // 1) Softfail CSRF? then update token & retry this file
if (data && data.csrf_expired) { if (data && data.csrf_expired) {
// Update global and Resumable headers // Update global and Resumable headers
window.csrfToken = data.csrf_token; window.csrfToken = data.csrf_token;
resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token; resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token;
resumableInstance.opts.query.upload_token = data.csrf_token; resumableInstance.opts.query.upload_token = data.csrf_token;
// Retry this chunk/file // Retry this chunk/file
file.retry(); file.retry();
return; return;
} }
// 2) Otherwise treat as real success: // 2) Otherwise treat as real success:
const li = document.querySelector( const li = document.querySelector(
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]` `li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
@@ -531,13 +586,13 @@ function initResumableUpload() {
const pauseResumeBtn = li.querySelector(".pause-resume-btn"); const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) pauseResumeBtn.style.display = "none"; if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
const removeBtn = li.querySelector(".remove-file-btn"); const removeBtn = li.querySelector(".remove-file-btn");
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);
}); });
resumableInstance.on("fileError", function (file, message) { resumableInstance.on("fileError", function (file, message) {
@@ -578,13 +633,24 @@ function initResumableUpload() {
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");
@@ -637,7 +703,7 @@ function submitFiles(allFiles) {
} catch (e) { } catch (e) {
jsonResponse = null; jsonResponse = null;
} }
// ─── Soft-fail CSRF: retry this upload ─────────────────────── // ─── Soft-fail CSRF: retry this upload ───────────────────────
if (jsonResponse && jsonResponse.csrf_expired) { if (jsonResponse && jsonResponse.csrf_expired) {
console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex); console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
@@ -650,10 +716,10 @@ function submitFiles(allFiles) {
xhr.send(formData); xhr.send(formData);
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) {
@@ -662,6 +728,7 @@ function submitFiles(allFiles) {
if (li.removeBtn) li.removeBtn.style.display = "none"; if (li.removeBtn) li.removeBtn.style.display = "none";
} }
uploadResults[file.uploadIndex] = true; uploadResults[file.uploadIndex] = true;
} else { } else {
// real failure // real failure
if (li) { if (li) {
@@ -681,12 +748,17 @@ function submitFiles(allFiles) {
} }
}, 5000); }, 5000);
} }
// ─── Only now count this chunk as finished ─────────────────── // ─── Only now count this chunk as finished ───────────────────
finishedCount++; finishedCount++;
if (finishedCount === allFiles.length) { if (finishedCount === allFiles.length) {
refreshFileList(allFiles, uploadResults, progressElements); const succeededCount = uploadResults.filter(Boolean).length;
} const failedCount = allFiles.length - succeededCount;
setTimeout(() => {
refreshFileList(allFiles, uploadResults, progressElements);
}, 250);
}
}); });
xhr.addEventListener("error", function () { xhr.addEventListener("error", function () {
@@ -699,6 +771,9 @@ function submitFiles(allFiles) {
finishedCount++; finishedCount++;
if (finishedCount === allFiles.length) { if (finishedCount === allFiles.length) {
refreshFileList(allFiles, uploadResults, progressElements); refreshFileList(allFiles, uploadResults, progressElements);
// Immediate summary toast based on actual XHR outcomes
const succeededCount = uploadResults.filter(Boolean).length;
const failedCount = allFiles.length - succeededCount;
} }
}); });
@@ -725,17 +800,30 @@ function submitFiles(allFiles) {
loadFileList(folderToUse) loadFileList(folderToUse)
.then(serverFiles => { .then(serverFiles => {
initFileActions(); initFileActions();
serverFiles = (serverFiles || []).map(item => item.name.trim().toLowerCase()); // Be tolerant to API shapes: string or object with name/fileName/filename
serverFiles = (serverFiles || [])
.map(item => {
if (typeof item === 'string') return item;
const n = item?.name ?? item?.fileName ?? item?.filename ?? '';
return String(n);
})
.map(s => s.trim().toLowerCase())
.filter(Boolean);
let overallSuccess = true; let overallSuccess = true;
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];
if (!uploadResults[file.uploadIndex] || !serverFiles.includes(clientFileName)) { const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
if (!uploadResults[file.uploadIndex] || (!hadRelative && !serverFiles.includes(clientFileName))) {
if (li) { if (li) {
li.progressBar.innerText = "Error"; li.progressBar.innerText = "Error";
} }
overallSuccess = false; overallSuccess = false;
} else if (li) { } else if (li) {
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();
@@ -757,9 +845,12 @@ function submitFiles(allFiles) {
}, 5000); }, 5000);
} }
}); });
if (!overallSuccess) { if (!overallSuccess) {
showToast("Some files failed to upload. Please check the list."); const failed = allFiles.length - succeeded;
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
} else {
showToast(`${succeeded} file succeeded. Please check the list.`);
} }
}) })
.catch(error => { .catch(error => {
@@ -768,6 +859,7 @@ function submitFiles(allFiles) {
}) })
.finally(() => { .finally(() => {
loadFolderTree(window.currentFolder); loadFolderTree(window.currentFolder);
}); });
} }
} }
@@ -804,7 +896,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) {
@@ -822,32 +915,48 @@ 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. // Ensure the lib/instance exists
for (let i = 0; i < fileInput.files.length; i++) { if (!_resumableReady) await initResumableUpload();
resumableInstance.addFile(fileInput.files[i]); if (resumableInstance) {
for (const f of files) resumableInstance.addFile(f);
} else {
// If still not ready (load error), fall back to your XHR path
processFiles(files);
} }
} else { } else {
processFiles(fileInput.files); 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 : []); const files = window.selectedFiles || (fileInput ? fileInput.files : []);
if (!files || files.length === 0) { 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 === "")) { // Resumable path (only for picked files, not folder uploads)
// Ensure current folder is updated. const first = files[0];
resumableInstance.opts.query.folder = window.currentFolder || "root"; const isFolderish = !!(first.customRelativePath || first.webkitRelativePath);
resumableInstance.upload(); if (useResumable && !isFolderish) {
showToast("Resumable upload started..."); if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
// ensure folder/token fresh
resumableInstance.opts.query.folder = window.currentFolder || "root";
resumableInstance.upload();
showToast("Resumable upload started...");
} else {
// fallback
submitFiles(files);
}
} else { } else {
submitFiles(files); submitFiles(files);
} }

2
public/js/version.js Normal file
View File

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

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/bootstrap/4.5.2/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
Bootstrap 4.5.2 — MIT
MIT License
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.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

21
public/vendor/codemirror/5.65.5/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
CodeMirror 5.65.5 — MIT
MIT License
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.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}(function(m){"use strict";var l={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function d(t,e){e=t.match(a[t=e]||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*")));return e?/^\s*(.*?)\s*$/.exec(e[2])[1]:""}function g(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function o(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],o=l.length-1;0<=o;o--)n.unshift(l[o])}m.defineMode("htmlmixed",function(i,t){var c=m.getMode(i,{name:"xml",htmlMode:!0,multilineTagIndentFactor:t.multilineTagIndentFactor,multilineTagIndentPastTag:t.multilineTagIndentPastTag,allowMissingTagName:t.allowMissingTagName}),s={},e=t&&t.tags,a=t&&t.scriptTypes;if(o(l,s),e&&o(e,s),a)for(var n=a.length-1;0<=n;n--)s.script.unshift(["type",a[n].matches,a[n].mode]);function u(t,e){var a,o,r,n=c.token(t,e.htmlState),l=/\btag\b/.test(n);return l&&!/[<>\s\/]/.test(t.current())&&(a=e.htmlState.tagName&&e.htmlState.tagName.toLowerCase())&&s.hasOwnProperty(a)?e.inTag=a+" ":e.inTag&&l&&/>$/.test(t.current())?(a=/^([\S]+) (.*)/.exec(e.inTag),e.inTag=null,l=">"==t.current()&&function(t,e){for(var a=0;a<t.length;a++){var n=t[a];if(!n[0]||n[1].test(d(e,n[0])))return n[2]}}(s[a[1]],a[2]),l=m.getMode(i,l),o=g(a[1],!0),r=g(a[1],!1),e.token=function(t,e){return t.match(o,!1)?(e.token=u,e.localState=e.localMode=null):(a=t,n=r,t=e.localMode.token(t,e.localState),e=a.current(),-1<(l=e.search(n))?a.backUp(e.length-l):e.match(/<\/?$/)&&(a.backUp(e.length),a.match(n,!1)||a.match(e)),t);var a,n,l},e.localMode=l,e.localState=m.startState(l,c.indent(e.htmlState,"",""))):e.inTag&&(e.inTag+=t.current(),t.eol()&&(e.inTag+=" ")),n}return{startState:function(){return{token:u,inTag:null,localMode:null,localState:null,htmlState:m.startState(c)}},copyState:function(t){var e;return t.localState&&(e=m.copyState(t.localMode,t.localState)),{token:t.token,inTag:t.inTag,localMode:t.localMode,localState:e,htmlState:m.copyState(c,t.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(t,e,a){return!t.localMode||/^\s*<\//.test(e)?c.indent(t.htmlState,e,a):t.localMode.indent?t.localMode.indent(t.localState,e,a):m.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||c}}}},"xml","javascript","css"),m.defineMIME("text/html","htmlmixed")});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("properties",function(){return{token:function(e,i){var t=e.sol()||i.afterSection,n=e.eol();if(i.afterSection=!1,t&&(i.nextMultiline?(i.inMultiline=!0,i.nextMultiline=!1):i.position="def"),n&&!i.nextMultiline&&(i.inMultiline=!1,i.position="def"),t)for(;e.eatSpace(););n=e.next();return!t||"#"!==n&&"!"!==n&&";"!==n?t&&"["===n?(i.afterSection=!0,e.skipTo("]"),e.eat("]"),"header"):"="===n||":"===n?(i.position="quote",null):("\\"===n&&"quote"===i.position&&e.eol()&&(i.nextMultiline=!0),i.position):(i.position="comment",e.skipToEnd(),"comment")},startState:function(){return{position:"def",nextMultiline:!1,inMultiline:!1,afterSection:!1}}}}),e.defineMIME("text/x-properties","properties"),e.defineMIME("text/x-ini","properties")});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(s){"use strict";s.defineMode("shell",function(){var o={};function e(e,t){for(var n=0;n<t.length;n++)o[t[n]]=e}var t=["true","false"],n=["if","then","do","else","elif","while","until","for","in","esac","fi","fin","fil","done","exit","set","unset","export","function"],r=["ab","awk","bash","beep","cat","cc","cd","chown","chmod","chroot","clear","cp","curl","cut","diff","echo","find","gawk","gcc","get","git","grep","hg","kill","killall","ln","ls","make","mkdir","openssl","mv","nc","nl","node","npm","ping","ps","restart","rm","rmdir","sed","service","sh","shopt","shred","source","sort","sleep","ssh","start","stop","su","sudo","svn","tee","telnet","top","touch","vi","vim","wall","wc","wget","who","write","yes","zsh"];function i(e,t){if(e.eatSpace())return null;var n,r=e.sol(),i=e.next();if("\\"===i)return e.next(),null;if("'"===i||'"'===i||"`"===i)return t.tokens.unshift(f(i,"`"===i?"quote":"string")),l(e,t);if("#"===i)return r&&e.eat("!")?(e.skipToEnd(),"meta"):(e.skipToEnd(),"comment");if("$"===i)return t.tokens.unshift(u),l(e,t);if("+"===i||"="===i)return"operator";if("-"===i)return e.eat("-"),e.eatWhile(/\w/),"attribute";if("<"==i){if(e.match("<<"))return"operator";r=e.match(/^<-?\s*['"]?([^'"]*)['"]?/);if(r)return t.tokens.unshift((n=r[1],function(e,t){return e.sol()&&e.string==n&&t.tokens.shift(),e.skipToEnd(),"string-2"})),"string-2"}if(/\d/.test(i)&&(e.eatWhile(/\d/),e.eol()||!/\w/.test(e.peek())))return"number";e.eatWhile(/[\w-]/);t=e.current();return"="===e.peek()&&/\w+/.test(t)?"def":o.hasOwnProperty(t)?o[t]:null}function f(i,o){var s="("==i?")":"{"==i?"}":i;return function(e,t){for(var n,r=!1;null!=(n=e.next());){if(n===s&&!r){t.tokens.shift();break}if("$"===n&&!r&&"'"!==i&&e.peek()!=s){r=!0,e.backUp(1),t.tokens.unshift(u);break}if(!r&&i!==s&&n===i)return t.tokens.unshift(f(i,o)),l(e,t);if(!r&&/['"]/.test(n)&&!/['"]/.test(i)){t.tokens.unshift(function(n,r){return function(e,t){return t.tokens[0]=f(n,r),e.next(),l(e,t)}}(n,"string")),e.backUp(1);break}r=!r&&"\\"===n}return o}}s.registerHelper("hintWords","shell",t.concat(n,r)),e("atom",t),e("keyword",n),e("builtin",r);var u=function(e,t){1<t.tokens.length&&e.eat("$");var n=e.next();return/['"({]/.test(n)?(t.tokens[0]=f(n,"("==n?"quote":"{"==n?"def":"string"),l(e,t)):(/\d/.test(n)||e.eatWhile(/\w/),t.tokens.shift(),"def")};function l(e,t){return(t.tokens[0]||i)(e,t)}return{startState:function(){return{tokens:[]}},token:l,closeBrackets:"()[]{}''\"\"``",lineComment:"#",fold:"brace"}}),s.defineMIME("text/x-sh","shell"),s.defineMIME("application/x-sh","shell")});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("yaml",function(){var n=new RegExp("\\b(("+["true","false","on","off","yes","no"].join(")|(")+"))$","i");return{token:function(e,i){var t=e.peek(),r=i.escaped;if(i.escaped=!1,"#"==t&&(0==e.pos||/\s/.test(e.string.charAt(e.pos-1))))return e.skipToEnd(),"comment";if(e.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/))return"string";if(i.literal&&e.indentation()>i.keyCol)return e.skipToEnd(),"string";if(i.literal&&(i.literal=!1),e.sol()){if(i.keyCol=0,i.pair=!1,i.pairStart=!1,e.match("---"))return"def";if(e.match("..."))return"def";if(e.match(/\s*-\s+/))return"meta"}if(e.match(/^(\{|\}|\[|\])/))return"{"==t?i.inlinePairs++:"}"==t?i.inlinePairs--:"["==t?i.inlineList++:i.inlineList--,"meta";if(0<i.inlineList&&!r&&","==t)return e.next(),"meta";if(0<i.inlinePairs&&!r&&","==t)return i.keyCol=0,i.pair=!1,i.pairStart=!1,e.next(),"meta";if(i.pairStart){if(e.match(/^\s*(\||\>)\s*/))return i.literal=!0,"meta";if(e.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i))return"variable-2";if(0==i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?$/))return"number";if(0<i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/))return"number";if(e.match(n))return"keyword"}return!i.pair&&e.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^,\[\]{}#&*!|>'"%@`])[^#]*?(?=\s*:($|\s))/)?(i.pair=!0,i.keyCol=e.indentation(),"atom"):i.pair&&e.match(/^:\s*/)?(i.pairStart=!0,"meta"):(i.pairStart=!1,i.escaped="\\"==t,e.next(),null)},startState:function(){return{pair:!1,pairStart:!1,keyCol:0,inlinePairs:0,inlineList:0,literal:!1,escaped:!1}},lineComment:"#",fold:"indent"}}),e.defineMIME("text/x-yaml","yaml"),e.defineMIME("text/yaml","yaml")});

View File

@@ -0,0 +1 @@
.cm-s-material-darker.CodeMirror{background-color:#212121;color:#eff}.cm-s-material-darker .CodeMirror-gutters{background:#212121;color:#545454;border:none}.cm-s-material-darker .CodeMirror-guttermarker,.cm-s-material-darker .CodeMirror-guttermarker-subtle,.cm-s-material-darker .CodeMirror-linenumber{color:#545454}.cm-s-material-darker .CodeMirror-cursor{border-left:1px solid #fc0}.cm-s-material-darker div.CodeMirror-selected{background:rgba(97,97,97,.2)}.cm-s-material-darker.CodeMirror-focused div.CodeMirror-selected{background:rgba(97,97,97,.2)}.cm-s-material-darker .CodeMirror-line::selection,.cm-s-material-darker .CodeMirror-line>span::selection,.cm-s-material-darker .CodeMirror-line>span>span::selection{background:rgba(128,203,196,.2)}.cm-s-material-darker .CodeMirror-line::-moz-selection,.cm-s-material-darker .CodeMirror-line>span::-moz-selection,.cm-s-material-darker .CodeMirror-line>span>span::-moz-selection{background:rgba(128,203,196,.2)}.cm-s-material-darker .CodeMirror-activeline-background{background:rgba(0,0,0,.5)}.cm-s-material-darker .cm-keyword{color:#c792ea}.cm-s-material-darker .cm-operator{color:#89ddff}.cm-s-material-darker .cm-variable-2{color:#eff}.cm-s-material-darker .cm-type,.cm-s-material-darker .cm-variable-3{color:#f07178}.cm-s-material-darker .cm-builtin{color:#ffcb6b}.cm-s-material-darker .cm-atom{color:#f78c6c}.cm-s-material-darker .cm-number{color:#ff5370}.cm-s-material-darker .cm-def{color:#82aaff}.cm-s-material-darker .cm-string{color:#c3e88d}.cm-s-material-darker .cm-string-2{color:#f07178}.cm-s-material-darker .cm-comment{color:#545454}.cm-s-material-darker .cm-variable{color:#f07178}.cm-s-material-darker .cm-tag{color:#ff5370}.cm-s-material-darker .cm-meta{color:#ffcb6b}.cm-s-material-darker .cm-attribute{color:#c792ea}.cm-s-material-darker .cm-property{color:#c792ea}.cm-s-material-darker .cm-qualifier{color:#decb6b}.cm-s-material-darker .cm-type,.cm-s-material-darker .cm-variable-3{color:#decb6b}.cm-s-material-darker .cm-error{color:#fff;background-color:#ff5370}.cm-s-material-darker .CodeMirror-matchingbracket{text-decoration:underline;color:#fff!important}

180
public/vendor/dompurify/2.4.0/LICENSE vendored Normal file
View File

@@ -0,0 +1,180 @@
DOMPurify 2.4.0 — Apache-2.0
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License.
Subject to the terms and conditions of this License, each Contributor
hereby grants to You a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable copyright license to reproduce, prepare
Derivative Works of, publicly display, publicly perform, sublicense,
and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License.
Subject to the terms and conditions of this License, each Contributor
hereby grants to You a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent
license to make, have made, use, offer to sell, sell, import, and
otherwise transfer the Work, where such license applies only to those
patent claims licensable by such Contributor that are necessarily
infringed by their Contribution(s) alone or by combination of their
Contribution(s) with the Work to which such Contribution(s) was submitted.
If You institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution.
You may reproduce and distribute copies of the Work or Derivative Works
thereof in any medium, with or without modifications, and in Source or
Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works
a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating
that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You
distribute, all copyright, patent, trademark, and attribution notices
from the Source form of the Work, excluding those notices that do not
pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution,
then any Derivative Works that You distribute must include a readable
copy of the attribution notices contained within such NOTICE file,
excluding those notices that do not pertain to any part of the
Derivative Works, in at least one of the following places: within a
NOTICE text file distributed as part of the Derivative Works; within
the Source form or documentation, if provided along with the Derivative
Works; or, within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents of the
NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works
that You distribute, alongside or as an addendum to the NOTICE text from
the Work, provided that such additional attribution notices cannot be
construed as modifying the License.
You may add Your own copyright statement to Your modifications and may
provide additional or different license terms and conditions for use,
reproduction, or distribution of Your modifications, or for any such
Derivative Works as a whole, provided Your use, reproduction, and
distribution of the Work otherwise complies with the conditions
stated in this License.
5. Submission of Contributions.
Unless You explicitly state otherwise, any Contribution intentionally
submitted for inclusion in the Work by You to the Licensor shall be
under the terms and conditions of this License, without any additional
terms or conditions. Notwithstanding the above, nothing herein shall
supersede or modify the terms of any separate license agreement you
may have executed with Licensor regarding such Contributions.
6. Trademarks.
This License does not grant permission to use the trade names, trademarks,
service marks, or product names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty.
Unless required by applicable law or agreed to in writing, Licensor provides
the Work (and each Contributor provides its Contributions) on an "AS IS"
BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions of
TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR
PURPOSE. You are solely responsible for determining the appropriateness
of using or redistributing the Work and assume any risks associated with
Your exercise of permissions under this License.
8. Limitation of Liability.
In no event and under no legal theory, whether in tort (including negligence),
contract, or otherwise, unless required by applicable law (such as deliberate
and grossly negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special, incidental,
or consequential damages of any character arising as a result of this License
or out of the use or inability to use the Work (including but not limited to
damages for loss of goodwill, work stoppage, computer failure or malfunction,
or any and all other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability.
While redistributing the Work or Derivative Works thereof, You may choose to
offer, and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this License.
However, in accepting such obligations, You may act only on Your own behalf
and on Your sole responsibility, not on behalf of any other Contributor,
and only if You agree to indemnify, defend, and hold each Contributor harmless
for any liability incurred by, or claims asserted against, such Contributor
by reason of your accepting any such warranty or additional liability.

File diff suppressed because one or more lines are too long

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