Compare commits

..

19 Commits

Author SHA1 Message Date
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
47 changed files with 4900 additions and 1328 deletions

View File

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

View File

@@ -3,13 +3,12 @@ name: Release on version.js update
on: on:
push: push:
branches: branches: ["master"]
- master
paths: paths:
- public/js/version.js - public/js/version.js
workflow_run: workflow_run:
workflows: "Bump version and sync Changelog to Docker Repo" workflows: ["Bump version and sync Changelog to Docker Repo"]
types: completed types: [completed]
permissions: permissions:
contents: write contents: write
@@ -27,6 +26,10 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Ensure tags available
run: |
git fetch --tags --force --prune --quiet
- name: Read version from version.js - name: Read version from version.js
id: ver id: ver
shell: bash shell: bash
@@ -45,7 +48,6 @@ jobs:
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
git fetch --tags --quiet
if git rev-parse -q --verify "refs/tags/${{ steps.ver.outputs.version }}" >/dev/null; then if git rev-parse -q --verify "refs/tags/${{ steps.ver.outputs.version }}" >/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT" echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${{ steps.ver.outputs.version }} already exists. Skipping release." echo "Tag ${{ steps.ver.outputs.version }} already exists. Skipping release."
@@ -53,7 +55,76 @@ jobs:
echo "exists=false" >> "$GITHUB_OUTPUT" echo "exists=false" >> "$GITHUB_OUTPUT"
fi fi
- name: Prepare release notes from CHANGELOG.md (optional) # Ensure the stamper is executable and has LF endings (helps if edited on Windows)
- name: Prep stamper 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 zip artifact (stamped)
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}" # e.g. v1.6.12
ZIP="FileRise-${VER}.zip"
# Clean staging copy (exclude dotfiles you dont want)
rm -rf staging
rsync -a \
--exclude '.git' --exclude '.github' \
--exclude 'resources' \
--exclude '.dockerignore' --exclude '.gitattributes' --exclude '.gitignore' \
./ staging/
# Stamp IN THE STAGING COPY (invoke via bash to avoid exec-bit issues)
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/staging"
- name: Verify placeholders are gone (staging)
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" \
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
echo "---- DEBUG (show 10 hits with context) ----"
grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
--include='*.html' --include='*.php' --include='*.css' --include='*.js' \
| head -n 10 | while IFS=: read -r file line _; do
echo ">>> $file:$line"
nl -ba "$file" | sed -n "$((line-3)),$((line+3))p" || true
echo "----------------------------------------"
done
exit 1
fi
echo "OK: No unreplaced placeholders in staging."
- name: Zip stamped staging
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
ZIP="FileRise-${VER}.zip"
(cd staging && zip -r "../$ZIP" . >/dev/null)
- name: Compute SHA-256 checksum
if: steps.tagcheck.outputs.exists == 'false'
id: sum
shell: bash
run: |
set -euo pipefail
ZIP="FileRise-${{ steps.ver.outputs.version }}.zip"
SHA=$(shasum -a 256 "$ZIP" | awk '{print $1}')
echo "$SHA $ZIP" > "${ZIP}.sha256"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "Computed SHA-256: $SHA"
- name: Extract notes from CHANGELOG (optional)
if: steps.tagcheck.outputs.exists == 'false' if: steps.tagcheck.outputs.exists == 'false'
id: notes id: notes
shell: bash shell: bash
@@ -66,45 +137,68 @@ jobs:
/^## / && !found {found=1} /^## / && !found {found=1}
found && /^---$/ {exit} found && /^---$/ {exit}
found {print} found {print}
' CHANGELOG.md > RELEASE_BODY.md || true ' CHANGELOG.md > CHANGELOG_SNIPPET.md || true
sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' CHANGELOG_SNIPPET.md || true
# Trim trailing blank lines if [[ -s CHANGELOG_SNIPPET.md ]]; then
sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' RELEASE_BODY.md || true NOTES_PATH="CHANGELOG_SNIPPET.md"
if [[ -s RELEASE_BODY.md ]]; then
NOTES_PATH="RELEASE_BODY.md"
fi fi
fi fi
echo "path=$NOTES_PATH" >> "$GITHUB_OUTPUT" echo "path=$NOTES_PATH" >> "$GITHUB_OUTPUT"
- name: (optional) Build archive to attach - name: Compute previous tag (for Full Changelog link)
if: steps.tagcheck.outputs.exists == 'false'
id: prev
shell: bash
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
PREV=$(git tag --list "v*" --sort=-v:refname | grep -v -F "$VER" | head -n1 || true)
if [[ -z "$PREV" ]]; then
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
fi
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
echo "Previous tag or baseline: $PREV"
- name: Build release body (snippet + full changelog + checksum)
if: steps.tagcheck.outputs.exists == 'false' if: steps.tagcheck.outputs.exists == 'false'
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
zip -r "FileRise-${{ steps.ver.outputs.version }}.zip" public/ README.md LICENSE >/dev/null || true VER="${{ steps.ver.outputs.version }}"
PREV="${{ steps.prev.outputs.prev }}"
REPO="${GITHUB_REPOSITORY}"
COMPARE_URL="https://github.com/${REPO}/compare/${PREV}...${VER}"
ZIP="FileRise-${VER}.zip"
SHA="${{ steps.sum.outputs.sha }}"
# Path A: we have extracted notes -> use body_path {
- name: Create GitHub Release (with CHANGELOG snippet) echo
if: steps.tagcheck.outputs.exists == 'false' && steps.notes.outputs.path != '' 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
echo "Release body:"
sed -n '1,200p' RELEASE_BODY.md
- name: Create GitHub Release
if: steps.tagcheck.outputs.exists == 'false'
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ steps.ver.outputs.version }} tag_name: ${{ steps.ver.outputs.version }}
target_commitish: ${{ github.sha }} target_commitish: ${{ github.sha }}
name: ${{ steps.ver.outputs.version }} name: ${{ steps.ver.outputs.version }}
body_path: ${{ steps.notes.outputs.path }} body_path: RELEASE_BODY.md
generate_release_notes: false generate_release_notes: false
files: | files: |
FileRise-${{ steps.ver.outputs.version }}.zip FileRise-${{ steps.ver.outputs.version }}.zip
FileRise-${{ steps.ver.outputs.version }}.zip.sha256
# Path B: no notes -> let GitHub auto-generate from commits
- name: Create GitHub Release (auto notes)
if: steps.tagcheck.outputs.exists == 'false' && steps.notes.outputs.path == ''
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.version }}
target_commitish: ${{ github.sha }}
name: ${{ steps.ver.outputs.version }}
generate_release_notes: true
files: |
FileRise-${{ steps.ver.outputs.version }}.zip

View File

@@ -32,7 +32,7 @@ jobs:
echo "No release(vX.Y.Z) tag in commit message; skipping bump." echo "No release(vX.Y.Z) tag in commit message; skipping bump."
fi fi
- name: Update public/js/version.js - name: Update public/js/version.js (source of truth)
if: steps.ver.outputs.version != '' if: steps.ver.outputs.version != ''
shell: bash shell: bash
run: | run: |
@@ -42,43 +42,20 @@ jobs:
window.APP_VERSION = '${{ steps.ver.outputs.version }}'; window.APP_VERSION = '${{ steps.ver.outputs.version }}';
EOF EOF
- name: Stamp asset cache-busters (?v=...) in HTML/CSS and {{APP_VER}} everywhere # ✂️ REMOVED: repo stamping of HTML/CSS/JS
if: steps.ver.outputs.version != ''
shell: bash
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}" # e.g. v1.6.9
QVER="${VER#v}" # e.g. 1.6.9
echo "Stamping ?v=${QVER} and {{APP_VER}}=${VER}"
# 1) Only stamp ?v= in HTML/CSS (avoid JS concatenation issues) - name: Commit version.js only
mapfile -t html_css < <(git ls-files -- 'public/*.html' 'public/**/*.html' 'public/*.css' 'public/**/*.css')
for f in "${html_css[@]}"; do
sed -E -i "s/(\?v=)[^\"'&<>\s]*/\1${QVER}/g" "$f"
sed -E -i "s/\{\{APP_VER\}\}/${VER}/g" "$f"
done
# 2) For JS, only replace the {{APP_VER}} placeholder (do NOT touch ?v=)
mapfile -t jsfiles < <(git ls-files -- 'public/*.js' 'public/**/*.js')
for f in "${jsfiles[@]}"; do
sed -E -i "s/\{\{APP_VER\}\}/${VER}/g" "$f"
done
echo "Changed files:"
git status --porcelain | awk '{print $2}' | sed 's/^/ - /'
- name: Commit version bump + stamped assets
if: steps.ver.outputs.version != '' if: steps.ver.outputs.version != ''
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add public/js/version.js public git add public/js/version.js
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No changes to commit" echo "No changes to commit"
else else
git commit -m "chore(release): set APP_VERSION and stamp assets to ${{ steps.ver.outputs.version }} [skip ci]" git commit -m "chore(release): set APP_VERSION to ${{ steps.ver.outputs.version }} [skip ci]"
git push git push
fi fi
@@ -110,6 +87,6 @@ jobs:
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No changes to commit" echo "No changes to commit"
else else
git commit -m "chore: sync CHANGELOG.md and VERSION (${{ steps.ver.outputs.version }}) from FileRise" git commit -m "chore: sync CHANGELOG.md + VERSION (${{ steps.ver.outputs.version }}) from FileRise"
git push origin main git push origin main
fi fi

View File

@@ -1,5 +1,218 @@
# Changelog # Changelog
## 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) ## Changes 10/27/2025 (v1.6.9)
release(v1.6.9): feat(core) localize assets, harden headers, and speed up load release(v1.6.9): feat(core) localize assets, harden headers, and speed up load

View File

@@ -139,7 +139,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" \
@@ -186,7 +186,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

View File

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

12
codeql-config.yml Normal file
View File

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

View File

@@ -1,22 +1,6 @@
<?php <?php
// config.php // config.php
// Prevent caching
header("Cache-Control: no-cache, must-revalidate");
header("Pragma: no-cache");
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
header("Expires: 0");
// Security headers
header('X-Content-Type-Options: nosniff');
header("X-Frame-Options: SAMEORIGIN");
header("Referrer-Policy: no-referrer-when-downgrade");
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
header("X-XSS-Protection: 1; mode=block");
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
header("Strict-Transport-Security: max-age=31536000; includeSubDomains; preload");
}
// Define constants // Define constants
define('PROJECT_ROOT', dirname(__DIR__)); define('PROJECT_ROOT', dirname(__DIR__));
define('UPLOAD_DIR', '/var/www/uploads/'); define('UPLOAD_DIR', '/var/www/uploads/');

View File

@@ -11,58 +11,83 @@ DirectoryIndex index.html
</IfModule> </IfModule>
RewriteEngine On RewriteEngine On
# --- HTTPS redirect ---
# Use ONE of these blocks.
# A) Direct TLS on this server (enable this if Apache terminates HTTPS here)
#RewriteCond %{HTTPS} off #RewriteCond %{HTTPS} off
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] #RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# MIME types for fonts/SVG # B) Behind a reverse proxy/CDN that sets X-Forwarded-Proto
#RewriteCond %{HTTP:X-Forwarded-Proto} =http [OR]
#RewriteCond %{HTTP:X-Forwarded-Proto} ^$
#RewriteCond %{HTTPS} !=on
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Don't interfere with ACME/http-01 if you do your own certs
#RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
#RewriteRule - - [L]
# --- MIME types (fonts/SVG/ESM) ---
<IfModule mod_mime.c> <IfModule mod_mime.c>
AddType font/woff2 .woff2 AddType font/woff2 .woff2
AddType font/woff .woff AddType font/woff .woff
AddType image/svg+xml .svg AddType image/svg+xml .svg
AddType application/javascript .mjs
</IfModule> </IfModule>
# Security headers # --- Security headers ---
<IfModule mod_headers.c> <IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN" Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block" Header always set X-XSS-Protection "1; mode=block"
Header always set X-Content-Type-Options "nosniff" Header always set X-Content-Type-Options "nosniff"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Header always set Referrer-Policy "strict-origin-when-cross-origin" Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()" Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
Header always set X-Download-Options "noopen" Header always set X-Download-Options "noopen"
Header always set Expect-CT "max-age=86400, enforce" Header always set Expect-CT "max-age=86400, enforce"
Header set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'" Header always set Cross-Origin-Resource-Policy "same-origin"
Header always set X-Permitted-Cross-Domain-Policies "none"
# HSTS only when actually on HTTPS
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" "expr=%{HTTPS} == 'on'"
# CSP (modules, blobs, workers, etc.)
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
</IfModule> </IfModule>
# Caching # --- Caching (query-string based, no env vars needed) ---
SetEnvIfNoCase QUERY_STRING "(^|&)v=" has_version_param=1
<IfModule mod_headers.c> <IfModule mod_headers.c>
# HTML/PHP: no cache (only if PHP didnt already set it)
<FilesMatch "\.(html?|php)$"> <FilesMatch "\.(html?|php)$">
Header set Cache-Control "no-cache, no-store, must-revalidate" Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache" Header setifempty Pragma "no-cache"
Header set Expires "0" Header setifempty Expires "0"
</FilesMatch> </FilesMatch>
# version.js: always non-cacheable
<FilesMatch "^js/version\.js$"> <FilesMatch "^js/version\.js$">
Header set Cache-Control "no-cache, no-store, must-revalidate" Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache" Header set Pragma "no-cache"
Header set Expires "0" Header set Expires "0"
</FilesMatch> </FilesMatch>
<FilesMatch "\.(js|css)$"> # Unversioned JS/CSS: 1 hour
Header set Cache-Control "public, max-age=3600, must-revalidate" env=!has_version_param <FilesMatch "\.(?:m?js|css)$">
Header set Cache-Control "public, max-age=3600, must-revalidate" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
</FilesMatch> </FilesMatch>
<FilesMatch "\.(png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$"> # Unversioned static (images/fonts): 7 days
Header set Cache-Control "public, max-age=604800" env=!has_version_param <FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
Header set Cache-Control "public, max-age=604800" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
</FilesMatch> </FilesMatch>
<FilesMatch "\.(js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$"> # Versioned assets (?v=...): 1 year + immutable
Header set Cache-Control "public, max-age=31536000, immutable" env=has_version_param <FilesMatch "\.(?:m?js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
Header setifempty Cache-Control "public, max-age=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/"
</FilesMatch> </FilesMatch>
</IfModule> </IfModule>
# Compression (only if module exists) # --- Compression ---
<IfModule mod_brotli.c> <IfModule mod_brotli.c>
BrotliCompressionQuality 5 BrotliCompressionQuality 5
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
@@ -71,6 +96,6 @@ SetEnvIfNoCase QUERY_STRING "(^|&)v=" has_version_param=1
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
</IfModule> </IfModule>
# Disable TRACE # --- Disable TRACE ---
RewriteCond %{REQUEST_METHOD} ^TRACE RewriteCond %{REQUEST_METHOD} ^TRACE
RewriteRule .* - [F] RewriteRule .* - [F]

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

View File

@@ -27,7 +27,9 @@ body {
padding-left: 30px !important; padding-left: 30px !important;
padding-right: 30px !important; padding-right: 30px !important;
}} }}
@media (max-width: 600px) {
.zones-toggle { left: 85px !important; }
}
/* =========================================================== /* ===========================================================
HEADER & NAVIGATION HEADER & NAVIGATION
=========================================================== */ =========================================================== */
@@ -35,7 +37,11 @@ body {
/************************************************************/ /************************************************************/
/* FLEXBOX HEADER: LOGO, TITLE, BUTTONS FIXED */ /* FLEXBOX HEADER: LOGO, TITLE, BUTTONS FIXED */
/************************************************************/ /************************************************************/
.header-logo .logo {
display:block;
max-width:100%;
height:auto; /* keep aspect ratio; HTML attrs set the intrinsic box */
}
.btn-login { .btn-login {
margin-top: 10px; margin-top: 10px;
}/* Color overrides */ }/* Color overrides */
@@ -1596,7 +1602,7 @@ body {
#removeUserModal { #removeUserModal {
z-index: 5000 !important; z-index: 5000 !important;
}#customConfirmModal { }#customConfirmModal {
z-index: 6000 !important; z-index: 12000 !important;
}.admin-panel-content { }.admin-panel-content {
background: #fff; background: #fff;
color: #000; color: #000;
@@ -1866,3 +1872,4 @@ body {
background: #fafafa; background: #fafafa;
border-color: #e2e2e2; border-color: #e2e2e2;
} }

View File

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

View File

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

View File

@@ -5,36 +5,58 @@
<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</title> <title>FileRise</title>
<!-- Icons -->
<link rel="icon" type="image/png" href="/assets/logo.png"> <link rel="icon" type="image/png" href="/assets/logo.png">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg"> <link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<!-- App meta -->
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
<meta name="csrf-token" content=""> <meta name="csrf-token" content="">
<meta name="share-url" content=""> <meta name="share-url" content="">
<meta name="theme-color" content="#0b5ed7">
<style>.main-wrapper{display:none}#loadingOverlay{position:fixed;inset:0;background:var(--bg-color,#fff);z-index:9999;display:flex;align-items:center;justify-content:center}</style> <!-- Minimal critical CSS only (keeps CSP clean, no inline JS) -->
<link rel="stylesheet" href="/css/vendor/roboto.css?v=dev"> <style>
<link rel="stylesheet" href="/css/vendor/material-icons.css?v=dev"> .main-wrapper{display:none}
#loadingOverlay{position:fixed;inset:0;background:var(--bg-color,#fff);z-index:9999;display:flex;align-items:center;justify-content:center}
</style>
<!-- Bootstrap CSS (local) --> <!-- CSS: preload, then promote via tiny external JS (no inline onload) -->
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v=dev"> <link rel="preload" as="style" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
<link rel="preload" as="style" href="/css/styles.css?v={{APP_QVER}}">
<!-- CodeMirror CSS (local) --> <!-- Fonts: preload only those used above the fold -->
<link rel="stylesheet" href="/vendor/codemirror/5.65.5/codemirror.min.css?v=dev"> <link rel="preload" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/vendor/codemirror/5.65.5/theme/material-darker.min.css?v=dev"> <link rel="preload" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" as="font" type="font/woff2" crossorigin>
<!-- Do NOT preload material icons unless needed above the fold -->
<!-- app CSS --> <!-- Non-blocking stylesheet promotion (external to satisfy CSP) -->
<link rel="stylesheet" href="/css/styles.css?v=dev"> <script src="/js/defer-css.js?v={{APP_QVER}}" defer></script>
<!-- Libraries (JS) -->
<script src="/vendor/dompurify/2.4.0/purify.min.js?v=dev"></script>
<script src="/vendor/fuse/6.6.2/fuse.min.js?v=dev"></script>
<script src="/vendor/resumable/1.1.0/resumable.min.js?v=dev"></script>
<!-- CodeMirror core FIRST, then modes --> <!-- Base CSS as a fallback if JS is disabled -->
<script src="/vendor/codemirror/5.65.5/codemirror.min.js?v=dev"></script> <noscript>
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
</noscript>
<script src="/js/version.js?v=dev"></script> <!-- Preload font CSS (non-blocking) -->
<script type="module" src="/js/main.js"></script> <link rel="preload" as="style" href="/css/vendor/roboto.css?v={{APP_QVER}}">
<link rel="preload" as="style" href="/css/vendor/material-icons.css?v={{APP_QVER}}">
<!-- Vendor JS (keep defer; theyre not modules) -->
<script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}" defer></script>
<!-- IMPORTANT: Remove CodeMirror here; lazy-load it inside your editor route/module. -->
<!-- Version marker (non-blocking) -->
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
<!-- App entry: start fetching early, execute after parse -->
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}">
<script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
</head> </head>
<body> <body>
@@ -42,67 +64,14 @@
<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" height="50"
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" /> decoding="async"
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" /> fetchpriority="low"
</linearGradient> />
<!-- 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>
@@ -161,7 +130,7 @@
<!-- 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">
<div class="row mt-4" id="loginForm"> <div class="row mt-4" id="loginForm">
<div class="col-12"> <div class="col-12">
<form id="authForm" method="post"> <form id="authForm" method="post">
@@ -173,7 +142,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>
@@ -191,6 +160,8 @@
</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">
@@ -273,7 +244,7 @@
<div class="modal-footer" style="margin-top:15px; text-align:right;"> <div class="modal-footer" style="margin-top:15px; text-align:right;">
<button id="cancelMoveFolder" class="btn btn-secondary" <button id="cancelMoveFolder" class="btn btn-secondary"
data-i18n-key="cancel">Cancel</button> data-i18n-key="cancel">Cancel</button>
<button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move">Move</button> <button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move" data-default>Move</button>
</div> </div>
</div> </div>
</div> </div>
@@ -291,7 +262,7 @@
<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>
@@ -311,7 +282,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>
@@ -347,7 +318,7 @@
selected files?</p> selected files?</p>
<div class="modal-footer"> <div class="modal-footer">
<button id="cancelDeleteFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelDeleteFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmDeleteFiles" class="btn btn-danger" data-i18n-key="delete">Delete</button> <button id="confirmDeleteFiles" class="btn btn-danger" data-i18n-key="delete" data-default>Delete</button>
</div> </div>
</div> </div>
</div> </div>
@@ -361,7 +332,7 @@
<select id="copyTargetFolder" class="form-control modal-input"></select> <select id="copyTargetFolder" class="form-control modal-input"></select>
<div class="modal-footer"> <div class="modal-footer">
<button id="cancelCopyFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelCopyFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmCopyFiles" class="btn btn-primary" data-i18n-key="copy">Copy</button> <button id="confirmCopyFiles" class="btn btn-primary" data-i18n-key="copy" data-default>Copy</button>
</div> </div>
</div> </div>
</div> </div>
@@ -375,7 +346,7 @@
<select id="moveTargetFolder" class="form-control modal-input"></select> <select id="moveTargetFolder" class="form-control modal-input"></select>
<div class="modal-footer"> <div class="modal-footer">
<button id="cancelMoveFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelMoveFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmMoveFiles" class="btn btn-primary" data-i18n-key="move">Move</button> <button id="confirmMoveFiles" class="btn btn-primary" data-i18n-key="move" data-default>Move</button>
</div> </div>
</div> </div>
</div> </div>
@@ -384,31 +355,16 @@
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" style="display: none;" disabled <button id="extractZipBtn" class="btn action-btn btn-sm btn-info" style="display: none;" disabled
data-i18n-key="extract_zip_button">Extract Zip</button> data-i18n-key="extract_zip_button">Extract Zip</button>
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;"> <div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
<button id="createBtn" class="btn action-btn" style="display: none;" data-i18n-key="create"> <button id="createBtn" class="btn action-btn" type="button" style="display:none;" aria-haspopup="true" aria-expanded="false">
${t('create')} <span class="material-icons" <span data-i18n-key="create">Create</span>
style="font-size:16px;vertical-align:middle;">arrow_drop_down</span> <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
</button> </button>
<ul id="createMenu" class="dropdown-menu" style=" <ul id="createMenu" class="dropdown-menu" style="display:none; position:absolute; top:100%; left:0; margin:4px 0 0; padding:0; list-style:none; background:#fff; border:1px solid #ccc; box-shadow:0 2px 6px rgba(0,0,0,0.2); z-index:10010; min-width:160px;">
display: none; <li id="createFileOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
position: absolute; <span data-i18n-key="create_file">Create file</span>
top: 100%;
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" <li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
style="padding:8px 12px; cursor:pointer;"> <span data-i18n-key="create_folder">Create folder</span>
${t('create_folder')}
</li> </li>
</ul> </ul>
</div> </div>
@@ -420,7 +376,7 @@
data-i18n-placeholder="newfile_placeholder" /> data-i18n-placeholder="newfile_placeholder" />
<div class="modal-footer" style="margin-top:1rem; text-align:right;"> <div class="modal-footer" style="margin-top:1rem; text-align:right;">
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button> <button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create" data-default>Create</button>
</div> </div>
</div> </div>
</div> </div>
@@ -432,7 +388,7 @@
placeholder="files.zip" /> placeholder="files.zip" />
<div class="modal-footer" style="margin-top:15px; text-align:right;"> <div class="modal-footer" style="margin-top:15px; text-align:right;">
<button id="cancelDownloadZip" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelDownloadZip" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmDownloadZip" class="btn btn-primary" data-i18n-key="download">Download</button> <button id="confirmDownloadZip" class="btn btn-primary" data-i18n-key="download" data-default>Download</button>
</div> </div>
</div> </div>
</div> </div>
@@ -470,14 +426,14 @@
placeholder="Filename" /> placeholder="Filename" />
<div style="margin-top: 15px; text-align: right;"> <div style="margin-top: 15px; text-align: right;">
<button id="cancelDownloadFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelDownloadFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download">Download</button> <button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download" data-default>Download</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) --> <!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
<div id="changePasswordModal" class="modal" style="display:none;"> <div id="changePasswordModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:400px; margin:auto;"> <div class="modal-content" style="text-align: center; padding: 20px;">
<span id="closeChangePasswordModal" class="editor-close-btn">&times;</span> <span id="closeChangePasswordModal" class="editor-close-btn">&times;</span>
<h3 data-i18n-key="change_password_title">Change Password</h3> <h3 data-i18n-key="change_password_title">Change Password</h3>
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password" <input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
@@ -486,7 +442,7 @@
placeholder="New Password" style="width:100%; margin: 5px 0;" /> placeholder="New Password" style="width:100%; margin: 5px 0;" />
<input type="password" id="confirmPassword" class="form-control" data-i18n-placeholder="confirm_new_password" <input type="password" id="confirmPassword" class="form-control" data-i18n-placeholder="confirm_new_password"
placeholder="Confirm New Password" style="width:100%; margin: 5px 0;" /> placeholder="Confirm New Password" style="width:100%; margin: 5px 0;" />
<button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;">Save</button> <button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;" data-default>Save</button>
</div> </div>
</div> </div>
<div id="addUserModal" class="modal" style="display:none;"> <div id="addUserModal" class="modal" style="display:none;">
@@ -511,7 +467,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>
@@ -536,7 +492,7 @@
placeholder="Enter new file name" style="margin-top:10px;" /> placeholder="Enter new file name" style="margin-top:10px;" />
<div style="margin-top:15px; text-align:right;"> <div style="margin-top:15px; text-align:right;">
<button id="cancelRenameFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <button id="cancelRenameFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="submitRenameFile" class="btn btn-primary" data-i18n-key="rename">Rename</button> <button id="submitRenameFile" class="btn btn-primary" data-i18n-key="rename" data-default>Rename</button>
</div> </div>
</div> </div>
</div> </div>
@@ -544,7 +500,7 @@
<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>

View File

@@ -1,8 +1,8 @@
// adminPanel.js // adminPanel.js
import { t } from './i18n.js'; import { t } from './i18n.js?v={{APP_QVER}}';
import { loadAdminConfigFunc } from './auth.js'; import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
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}}';
const version = window.APP_VERSION || "dev"; const version = window.APP_VERSION || "dev";
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`; const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
@@ -37,6 +37,66 @@ function enforceShareFolderRule(row) {
} }
} }
function wireHeaderTitleLive() {
const input = document.getElementById('headerTitle');
if (!input || input.__live) return;
input.__live = true;
const apply = (val) => {
const title = (val || '').trim() || 'FileRise';
const h1 = document.querySelector('.header-title h1');
if (h1) h1.textContent = title;
document.title = title;
window.headerTitle = val || ''; // preserve raw value user typed
try { localStorage.setItem('headerTitle', title); } catch { }
};
// apply current value immediately + on each keystroke
apply(input.value);
input.addEventListener('input', (e) => apply(e.target.value));
}
function renderMaskedInput({ id, label, hasValue, isSecret = false }) {
const type = isSecret ? 'password' : 'text';
const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : '';
const replaceBtn = hasValue
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
: '';
const note = hasValue
? `<small class="text-success" style="margin-left:4px;">Saved — leave blank to keep</small>`
: '';
return `
<div class="form-group">
<label for="${id}">${label}:</label>
<div style="display:flex; gap:8px; align-items:center;">
<input type="${type}" id="${id}" class="form-control" ${disabled} />
${replaceBtn}
</div>
${note}
</div>
`;
}
function wireReplaceButtons(scope = document) {
scope.querySelectorAll('[data-replace-for]').forEach(btn => {
if (btn.__wired) return;
btn.__wired = true;
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-replace-for');
const inp = scope.querySelector('#' + id);
if (!inp) return;
inp.disabled = false;
inp.dataset.replace = '1';
inp.placeholder = '';
inp.value = '';
btn.textContent = 'Keep saved value';
btn.removeAttribute('data-replace-for');
btn.addEventListener('click', () => { /* no-op after first toggle */ }, { once: true });
}, { once: true });
});
}
function onShareFolderToggle(row, checked) { function onShareFolderToggle(row, checked) {
const manage = qs(row, 'input[data-cap="manage"]'); const manage = qs(row, 'input[data-cap="manage"]');
const viewAll = qs(row, 'input[data-cap="view"]'); const viewAll = qs(row, 'input[data-cap="view"]');
@@ -433,7 +493,8 @@ export function openAdminPanel() {
{ id: "webdav", label: "WebDAV Access" }, { id: "webdav", label: "WebDAV Access" },
{ id: "upload", label: t("shared_max_upload_size_bytes_title") }, { id: "upload", label: t("shared_max_upload_size_bytes_title") },
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" }, { id: "oidc", label: t("oidc_configuration") + " & TOTP" },
{ id: "shareLinks", label: t("manage_shared_links") } { id: "shareLinks", label: t("manage_shared_links") },
{ id: "sponsor", label: (typeof tf === 'function' ? tf("sponsor_donations", "Sponsor / Donations") : "Sponsor / Donations") }
].map(sec => ` ].map(sec => `
<div id="${sec.id}Header" class="section-header collapsed"> <div id="${sec.id}Header" class="section-header collapsed">
${sec.label} <i class="material-icons">expand_more</i> ${sec.label} <i class="material-icons">expand_more</i>
@@ -453,7 +514,7 @@ export function openAdminPanel() {
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel); document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel); document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks"] ["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks", "sponsor"]
.forEach(id => { .forEach(id => {
document.getElementById(id + "Header") document.getElementById(id + "Header")
.addEventListener("click", () => toggleSection(id)); .addEventListener("click", () => toggleSection(id));
@@ -485,6 +546,7 @@ export function openAdminPanel() {
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle || ""}" /> <input type="text" id="headerTitle" class="form-control" value="${window.headerTitle || ""}" />
</div> </div>
`; `;
wireHeaderTitleLive();
document.getElementById("loginOptionsContent").innerHTML = ` document.getElementById("loginOptionsContent").innerHTML = `
<div class="form-group"><input type="checkbox" id="disableFormLogin" /> <label for="disableFormLogin">${t("disable_login_form")}</label></div> <div class="form-group"><input type="checkbox" id="disableFormLogin" /> <label for="disableFormLogin">${t("disable_login_form")}</label></div>
@@ -512,17 +574,35 @@ export function openAdminPanel() {
</div> </div>
`; `;
const hasId = !!(config.oidc && config.oidc.hasClientId);
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
document.getElementById("oidcContent").innerHTML = ` document.getElementById("oidcContent").innerHTML = `
<div class="form-text text-muted" style="margin-top:8px;"> <div class="form-text text-muted" style="margin-top:8px;">
<small>Note: OIDC credentials (Client ID/Secret) will show blank here after saving, but remain unchanged until you explicitly edit and save them.</small> <small>Client ID/Secret are never shown after saving. A green note indicates a value is saved. Click “Replace” to overwrite.</small>
</div>
<div class="form-group">
<label for="oidcProviderUrl">${t("oidc_provider_url")}:</label>
<input type="text" id="oidcProviderUrl" class="form-control" value="${(window.currentOIDCConfig?.providerUrl || "")}" />
</div>
${renderMaskedInput({ id: "oidcClientId", label: t("oidc_client_id"), hasValue: hasId })}
${renderMaskedInput({ id: "oidcClientSecret", label: t("oidc_client_secret"), hasValue: hasSecret, isSecret: true })}
<div class="form-group">
<label for="oidcRedirectUri">${t("oidc_redirect_uri")}:</label>
<input type="text" id="oidcRedirectUri" class="form-control" value="${(window.currentOIDCConfig?.redirectUri || "")}" />
</div>
<div class="form-group">
<label for="globalOtpauthUrl">${t("global_otpauth_url")}:</label>
<input type="text" id="globalOtpauthUrl" class="form-control" value="${window.currentOIDCConfig?.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'}" />
</div> </div>
<div class="form-group"><label for="oidcProviderUrl">${t("oidc_provider_url")}:</label><input type="text" id="oidcProviderUrl" class="form-control" value="${window.currentOIDCConfig?.providerUrl || ""}" /></div>
<div class="form-group"><label for="oidcClientId">${t("oidc_client_id")}:</label><input type="text" id="oidcClientId" class="form-control" value="${window.currentOIDCConfig?.clientId || ""}" /></div>
<div class="form-group"><label for="oidcClientSecret">${t("oidc_client_secret")}:</label><input type="text" id="oidcClientSecret" class="form-control" value="${window.currentOIDCConfig?.clientSecret || ""}" /></div>
<div class="form-group"><label for="oidcRedirectUri">${t("oidc_redirect_uri")}:</label><input type="text" id="oidcRedirectUri" class="form-control" value="${window.currentOIDCConfig?.redirectUri || ""}" /></div>
<div class="form-group"><label for="globalOtpauthUrl">${t("global_otpauth_url")}:</label><input type="text" id="globalOtpauthUrl" class="form-control" value="${window.currentOIDCConfig?.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'}" /></div>
`; `;
wireReplaceButtons(document.getElementById("oidcContent"));
document.getElementById("shareLinksContent").textContent = t("loading") + "…"; document.getElementById("shareLinksContent").textContent = t("loading") + "…";
document.getElementById("saveAdminSettings") document.getElementById("saveAdminSettings")
@@ -545,6 +625,60 @@ export function openAdminPanel() {
} }
}); });
// --- Sponsor (fixed, non-editable) ---
const SPONSOR_GH = "https://github.com/sponsors/error311";
const SPONSOR_KOFI = "https://ko-fi.com/error311";
document.getElementById("sponsorContent").innerHTML = `
<div class="form-group" style="margin-bottom:12px;">
<label for="sponsorGitHub">${(typeof tf === 'function' ? tf("github_sponsors_url", "GitHub Sponsors URL") : "GitHub Sponsors URL")}:</label>
<div class="input-group">
<input type="url"
id="sponsorGitHub"
class="form-control"
value="${SPONSOR_GH}"
readonly
data-ignore-dirty="1" />
<button type="button" id="copySponsorGitHub" class="btn btn-outline-primary">Copy</button>
<a class="btn btn-outline-secondary" id="openSponsorGitHub" target="_blank" rel="noopener">Open</a>
</div>
</div>
<div class="form-group" style="margin-bottom:12px;">
<label for="sponsorKoFi">${(typeof tf === 'function' ? tf("ko_fi_url", "Ko-fi URL") : "Ko-fi URL")}:</label>
<div class="input-group">
<input type="url"
id="sponsorKoFi"
class="form-control"
value="${SPONSOR_KOFI}"
readonly
data-ignore-dirty="1" />
<button type="button" id="copySponsorKoFi" class="btn btn-outline-primary">Copy</button>
<a class="btn btn-outline-secondary" id="openSponsorKoFi" target="_blank" rel="noopener">Open</a>
</div>
</div>
<small class="text-muted">${(typeof tf === 'function'
? tf("sponsor_note_fixed", "Please consider supporting ongoing development.")
: "Please consider supporting ongoing development.")}</small>
`;
// Wire copy + open (no changes tracked)
const ghInput = document.getElementById("sponsorGitHub");
const kfInput = document.getElementById("sponsorKoFi");
document.getElementById("copySponsorGitHub").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(ghInput.value); } catch { }
showToast(typeof tf === 'function' ? tf("copied", "Copied!") : "Copied!");
});
document.getElementById("copySponsorKoFi").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(kfInput.value); } catch { }
showToast(typeof tf === 'function' ? tf("copied", "Copied!") : "Copied!");
});
document.getElementById("openSponsorGitHub").href = SPONSOR_GH;
document.getElementById("openSponsorKoFi").href = SPONSOR_KOFI;
const userMgmt = document.getElementById("userManagementContent"); const userMgmt = document.getElementById("userManagementContent");
userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick); userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick);
window.__userMgmtDelegatedClick = (e) => { window.__userMgmtDelegatedClick = (e) => {
@@ -574,7 +708,11 @@ export function openAdminPanel() {
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true; document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || ""; document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || ""; document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || "";
document.getElementById("oidcClientId").value = window.currentOIDCConfig?.clientId || ""; const idEl = document.getElementById("oidcClientId");
const secEl = document.getElementById("oidcClientSecret");
if (!hasId) idEl.value = window.currentOIDCConfig?.clientId || "";
if (!hasSecret) secEl.value = window.currentOIDCConfig?.clientSecret || "";
wireReplaceButtons(document.getElementById("oidcContent"));
document.getElementById("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || ""; document.getElementById("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || "";
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || ""; document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || "";
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || ''; document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || '';
@@ -585,57 +723,57 @@ export function openAdminPanel() {
} }
function handleSave() { function handleSave() {
const dFL = !!document.getElementById("disableFormLogin")?.checked; const payload = {
const dBA = !!document.getElementById("disableBasicAuth")?.checked; header_title: document.getElementById("headerTitle")?.value || "",
const dOIDC = !!document.getElementById("disableOIDCLogin")?.checked;
const aBypass = !!document.getElementById("authBypass")?.checked;
const aHeader = (document.getElementById("authHeaderName")?.value || "X-Remote-User").trim();
const eWD = !!document.getElementById("enableWebDAV")?.checked;
const sMax = parseInt(document.getElementById("sharedMaxUploadSize")?.value || "0", 10) || 0;
const nHT = (document.getElementById("headerTitle")?.value || "").trim();
const nOIDC = {
providerUrl: (document.getElementById("oidcProviderUrl")?.value || "").trim(),
clientId: (document.getElementById("oidcClientId")?.value || "").trim(),
clientSecret: (document.getElementById("oidcClientSecret")?.value || "").trim(),
redirectUri: (document.getElementById("oidcRedirectUri")?.value || "").trim()
};
const gURL = (document.getElementById("globalOtpauthUrl")?.value || "").trim();
if ([dFL, dBA, dOIDC].filter(x => x).length === 3) {
showToast(t("at_least_one_login_method"));
return;
}
sendRequest("/api/admin/updateConfig.php", "POST", {
header_title: nHT,
oidc: nOIDC,
loginOptions: { loginOptions: {
disableFormLogin: dFL, disableFormLogin: document.getElementById("disableFormLogin").checked,
disableBasicAuth: dBA, disableBasicAuth: document.getElementById("disableBasicAuth").checked,
disableOIDCLogin: dOIDC, disableOIDCLogin: document.getElementById("disableOIDCLogin").checked,
authBypass: aBypass, authBypass: document.getElementById("authBypass").checked,
authHeaderName: aHeader authHeaderName: document.getElementById("authHeaderName").value.trim() || "X-Remote-User",
}, },
enableWebDAV: eWD, enableWebDAV: document.getElementById("enableWebDAV").checked,
sharedMaxUploadSize: sMax, sharedMaxUploadSize: parseInt(document.getElementById("sharedMaxUploadSize").value || "0", 10) || 0,
globalOtpauthUrl: gURL oidc: {
}, { "X-CSRF-Token": window.csrfToken }) providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
.then(res => { redirectUri: document.getElementById("oidcRedirectUri").value.trim(),
if (res.success) { // clientId/clientSecret: only include when replacing
showToast(t("settings_updated_successfully"), "success"); },
captureInitialAdminConfig(); globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim(),
closeAdminPanel(); };
loadAdminConfigFunc();
} else { const idEl = document.getElementById("oidcClientId");
showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error"); const scEl = document.getElementById("oidcClientSecret");
if (idEl?.dataset.replace === '1' && idEl.value.trim() !== '') {
payload.oidc.clientId = idEl.value.trim();
} }
}).catch(() => {/*noop*/ }); if (scEl?.dataset.replace === '1' && scEl.value.trim() !== '') {
payload.oidc.clientSecret = scEl.value.trim();
}
fetch('/api/admin/updateConfig.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '')
},
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(j => {
if (j.error) { showToast('Error: ' + j.error); return; }
showToast('Settings saved.');
closeAdminPanel();
})
.catch(() => showToast('Save failed.'));
} }
export async function closeAdminPanel() { export async function closeAdminPanel() {
if (hasUnsavedChanges()) { if (hasUnsavedChanges()) {
const ok = await showCustomConfirmModal(t("unsaved_changes_confirm")); //const ok = await showCustomConfirmModal(t("unsaved_changes_confirm"));
if (!ok) return; //if (!ok) return;
} }
const m = document.getElementById("adminPanelModal"); const m = document.getElementById("adminPanelModal");
if (m) m.style.display = "none"; if (m) m.style.display = "none";

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

@@ -0,0 +1,177 @@
// /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 } from './fileActions.js?v={{APP_QVER}}';
import { initUpload } from './upload.js?v={{APP_QVER}}';
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
// 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('fileListContainer');
const uploadArea = document.getElementById('uploadDropArea');
if (fileListArea && uploadArea) {
fileListArea.addEventListener('dragover', e => {
e.preventDefault();
fileListArea.classList.add('drop-hover');
});
fileListArea.addEventListener('dragleave', () => {
fileListArea.classList.remove('drop-hover');
});
fileListArea.addEventListener('drop', e => {
e.preventDefault();
fileListArea.classList.remove('drop-hover');
uploadArea.dispatchEvent(new DragEvent('drop', {
dataTransfer: e.dataTransfer,
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) {
options.headers['X-CSRF-Token'] = window.csrfToken;
}
// 2) First attempt async function retryWithFreshCsrf(asFormFallback = false) {
let res = await originalFetch(url, options); const tokRes = await original('/api/auth/token.php', { credentials: 'include' });
// 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) { if (tokRes.ok) {
const body = await tokRes.json(); const body = await tokRes.json().catch(() => ({}));
newToken = body.csrf_token; if (body?.csrf_token) {
} window.csrfToken = body.csrf_token;
}
if (newToken) {
// 3c) Update global + meta
window.csrfToken = newToken;
const meta = document.querySelector('meta[name="csrf-token"]'); const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) meta.content = newToken; if (meta) meta.content = body.csrf_token;
options.headers['X-CSRF-Token'] = body.csrf_token;
// 3d) Retry the original request with the new token
options.headers['X-CSRF-Token'] = newToken;
res = await originalFetch(url, options);
} }
} }
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);
}
// 4) Return the real Response—no body peeking here! 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 = {};
@@ -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 submitLogin(data) { async function primeCsrfStrict() {
setLastLoginData(data); const r = await fetch('/api/auth/token.php', { credentials: 'include' });
window.__lastLoginData = data; const j = await r.json().catch(() => ({}));
if (!r.ok || !j.csrf_token) throw new Error('CSRF missing');
try { window.csrfToken = j.csrf_token;
// ─── 1) Get CSRF for the initial auth call ─── const m = document.querySelector('meta[name="csrf-token"]');
let res = await fetch("/api/auth/token.php", { credentials: "include" }); if (m) m.content = j.csrf_token;
if (!res.ok) throw new Error("Could not fetch CSRF token");
window.csrfToken = (await res.json()).csrf_token;
// ─── 2) Send credentials ───
const response = await sendRequest(
"/api/auth/auth.php",
"POST",
data,
{ "X-CSRF-Token": window.csrfToken }
);
// ─── 3a) Full login (no TOTP) ───
if (response.success || response.status === "ok") {
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!");
// … fetch permissions & reload …
try {
const perm = await sendRequest("/api/getUserPermissions.php", "GET");
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");
} }
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 { } } catch { }
return window.location.reload(); return false;
} }
// ─── 3b) TOTP required ─── async function isAuthedNow() {
if (response.totp_required) { try {
// **Refresh** CSRF before the TOTP verify call const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
res = await fetch("/api/auth/token.php", { credentials: "include" }); const j = await r.json().catch(() => ({}));
if (res.ok) { return !!j.authenticated;
window.csrfToken = (await res.json()).csrf_token; } catch { return false; }
}
// now open the modal—any totp_verify fetch from here on will use the new token
return openTOTPLoginModal();
} }
// ─── 3c) Too many attempts ─── function rafTick(times = 2) {
if (response.error && response.error.includes("Too many failed login attempts")) { return new Promise(res => {
showToast(response.error); 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) {
if (__loginInFlight) return;
__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 {
await primeCsrfStrict();
// Attempt #1 — JSON
let res = await fetchWithCsrf('/api/auth/auth.php', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(payload)
});
let body = await safeJson(res);
// TOTP requested?
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;
}
// Full success (no TOTP)
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
await syncPermissionsToLocalStorage();
return afterLogin();
}
// Cookie set but non-JSON body — double check session
if (!body && await isAuthedNow()) {
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) {

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

@@ -0,0 +1,20 @@
// Promote any preloaded styles to real stylesheets without inline handlers (CSP-safe)
document.addEventListener('DOMContentLoaded', () => {
// Promote any preloaded core CSS
document.querySelectorAll('link[rel="preload"][as="style"][href]').forEach(link => {
const href = link.getAttribute('href');
if ([...document.querySelectorAll('link[rel="stylesheet"]')]
.some(s => s.getAttribute('href') === href)) return;
const sheet = document.createElement('link');
sheet.rel = 'stylesheet';
sheet.href = href;
document.head.appendChild(sheet);
});
// Optionally load non-critical icon/extra font CSS after first paint:
const extra = document.createElement('link');
extra.rel = 'stylesheet';
extra.href = '/css/vendor/material-icons.css?v={{APP_QVER}}';
document.head.appendChild(extra);
});

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) {

View File

@@ -489,6 +489,7 @@ function mountHeaderToggle(btn) {
}); });
} }
function ensureZonesToggle() { function ensureZonesToggle() {
let btn = document.getElementById('sidebarToggleFloating'); let btn = document.getElementById('sidebarToggleFloating');
const host = getHeaderHost(); const host = getHeaderHost();
@@ -502,24 +503,25 @@ function ensureZonesToggle() {
if (!btn) { if (!btn) {
btn = document.createElement('button'); btn = document.createElement('button');
btn.id = 'sidebarToggleFloating'; btn.id = 'sidebarToggleFloating';
btn.type = 'button'; // not a submit btn.type = 'button';
btn.setAttribute('aria-label', 'Toggle panels');
// Prevent accidental navigations / bubbling
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // don't bubble into the <a href="index.html"> e.stopPropagation();
setSidebarCollapsed(!isSidebarCollapsed()); setSidebarCollapsed(!isSidebarCollapsed());
updateSidebarToggleUI(); // refresh icon/title updateSidebarToggleUI();
}); });
['mousedown','mouseup','pointerdown','pointerup'].forEach(evt => ['mousedown','mouseup','pointerdown','pointerup'].forEach(evt =>
btn.addEventListener(evt, (e) => e.stopPropagation()) btn.addEventListener(evt, (e) => e.stopPropagation())
); );
btn.setAttribute('aria-label', 'Toggle panels');
Object.assign(btn.style, { Object.assign(btn.style, {
position: 'absolute', // <-- key change (was fixed) position: 'absolute',
top: '8px', // adjust to line up with header content top: '8px',
left: '65px', // place to the right of your logo; tweak as needed left: '65px',
zIndex: '1000', zIndex: '1000',
width: '38px', width: '38px',
height: '38px', height: '38px',
@@ -534,8 +536,9 @@ btn.addEventListener('click', (e) => {
padding: '0', padding: '0',
lineHeight: '0' lineHeight: '0'
}); });
btn.classList.add('zones-toggle');
// dark-mode polish (optional) // Dark mode polish
if (document.body.classList.contains('dark-mode')) { if (document.body.classList.contains('dark-mode')) {
btn.style.background = '#2c2c2c'; btn.style.background = '#2c2c2c';
btn.style.border = '1px solid #555'; btn.style.border = '1px solid #555';
@@ -547,13 +550,14 @@ btn.addEventListener('click', (e) => {
setZonesCollapsed(!isZonesCollapsed()); setZonesCollapsed(!isZonesCollapsed());
}); });
// Insert right after the logo if present, else just append to host // Insert right after the logo if present, else append to host
const afterLogo = host.querySelector('.header-logo'); const afterLogo = host.querySelector('.header-logo');
if (afterLogo && afterLogo.parentNode) { if (afterLogo && afterLogo.parentNode) {
afterLogo.parentNode.insertBefore(btn, afterLogo.nextSibling); afterLogo.parentNode.insertBefore(btn, afterLogo.nextSibling);
} else { } else {
host.appendChild(btn); host.appendChild(btn);
} }
themeToggleButton(btn); themeToggleButton(btn);
} }

View File

@@ -1,8 +1,8 @@
// 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 { t } from './i18n.js?v={{APP_QVER}}';
export function handleDeleteSelected(e) { export function handleDeleteSelected(e) {
e.preventDefault(); e.preventDefault();
@@ -31,6 +31,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",
@@ -316,6 +317,7 @@ 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();
@@ -478,6 +480,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) {
@@ -529,6 +532,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) {
@@ -598,6 +602,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) {

View File

@@ -1,6 +1,6 @@
// 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) { export function fileDragStartHandler(event) {
const row = event.currentTarget; const row = event.currentTarget;

View File

@@ -1,43 +1,58 @@
// 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}}';
// thresholds for editor behavior // thresholds for editor behavior
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
// Lazy-load CodeMirror modes on demand // ==== CodeMirror lazy loader ===============================================
//const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/"; const CM_BASE = "/vendor/codemirror/5.65.5/";
const CM_LOCAL = "/vendor/codemirror/5.65.5/";
// Stamp-friendly helpers (the stamper will replace {{APP_QVER}})
const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`;
const CORE = {
js: coreUrl("codemirror.min.js"),
css: coreUrl("codemirror.min.css"),
themeCss: coreUrl("theme/material-darker.min.css"),
};
// Which mode file to load for a given name/mime // Which mode file to load for a given name/mime
const MODE_URL = { const MODE_URL = {
// core/common // core/common
"xml": "mode/xml/xml.min.js", "xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
"css": "mode/css/css.min.js", "css": "mode/css/css.min.js?v={{APP_QVER}}",
"javascript": "mode/javascript/javascript.min.js", "javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}",
// meta / combos // meta / combos
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js", "htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
"application/x-httpd-php": "mode/php/php.min.js", "application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}",
// docs / data // docs / data
"markdown": "mode/markdown/markdown.min.js", "markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
"yaml": "mode/yaml/yaml.min.js", "yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
"properties": "mode/properties/properties.min.js", "properties": "mode/properties/properties.min.js?v={{APP_QVER}}",
"sql": "mode/sql/sql.min.js", "sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
// shells // shells
"shell": "mode/shell/shell.min.js", "shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
// languages // languages
"python": "mode/python/python.min.js", "python": "mode/python/python.min.js?v={{APP_QVER}}",
"text/x-csrc": "mode/clike/clike.min.js", "text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-c++src": "mode/clike/clike.min.js", "text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-java": "mode/clike/clike.min.js", "text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-csharp": "mode/clike/clike.min.js", "text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-kotlin": "mode/clike/clike.min.js" "text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
};
// Mode dependency graph
const MODE_DEPS = {
"htmlmixed": ["xml", "javascript", "css"],
"application/x-httpd-php": ["htmlmixed", "text/x-csrc"], // php overlays + clike bits
"markdown": ["xml"]
}; };
// Map any mime/alias to the key we use in MODE_URL // Map any mime/alias to the key we use in MODE_URL
@@ -49,61 +64,77 @@ function normalizeModeName(modeOption) {
return name; return name;
} }
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever const _loadedScripts = new Set();
const _loadedCss = new Set();
let _corePromise = null;
function loadScriptOnce(url) { function loadScriptOnce(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ver = (window.APP_VERSION ?? 'dev').replace(/^v/, ''); // "v1.6.9" -> "1.6.9" if (_loadedScripts.has(url)) return resolve();
const withQS = url + '?v=' + ver; const s = document.createElement("script");
s.src = url;
const key = `cm:${withQS}`;
let s = document.querySelector(`script[data-key="${key}"]`);
if (s) {
if (s.dataset.loaded === "1") return resolve();
s.addEventListener("load", resolve);
s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`)));
return;
}
s = document.createElement("script");
s.src = withQS;
s.async = true; s.async = true;
s.dataset.key = key; s.onload = () => { _loadedScripts.add(url); resolve(); };
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); }); s.onerror = () => reject(new Error(`Load failed: ${url}`));
s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`)));
document.head.appendChild(s); document.head.appendChild(s);
}); });
} }
function loadCssOnce(href) {
return new Promise((resolve, reject) => {
if (_loadedCss.has(href)) return resolve();
const l = document.createElement("link");
l.rel = "stylesheet";
l.href = href;
l.onload = () => { _loadedCss.add(href); resolve(); };
l.onerror = () => reject(new Error(`Load failed: ${href}`));
document.head.appendChild(l);
});
}
async function ensureCore() {
if (_corePromise) return _corePromise;
_corePromise = (async () => {
// load CSS first to avoid FOUC
await loadCssOnce(CORE.css);
await loadCssOnce(CORE.themeCss);
if (!window.CodeMirror) {
await loadScriptOnce(CORE.js);
}
})();
return _corePromise;
}
async function loadSingleMode(name) {
const rel = MODE_URL[name];
if (!rel) return;
// prepend base if needed
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
await loadScriptOnce(url);
}
function isModeRegistered(name) {
return !!(
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) ||
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name])
);
}
async function ensureModeLoaded(modeOption) { async function ensureModeLoaded(modeOption) {
if (!window.CodeMirror) return; await ensureCore();
const name = normalizeModeName(modeOption); const name = normalizeModeName(modeOption);
if (!name) return; if (!name) return;
if (isModeRegistered(name)) return;
const isRegistered = () => const deps = MODE_DEPS[name] || [];
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) || for (const d of deps) {
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name]); if (!isModeRegistered(d)) await loadSingleMode(d);
if (isRegistered()) return;
const url = MODE_URL[name];
if (!url) return; // unknown -> stay in text/plain
// Dependencies
if (name === "htmlmixed") {
await Promise.all([
ensureModeLoaded("xml"),
ensureModeLoaded("css"),
ensureModeLoaded("javascript")
]);
} }
if (name === "application/x-httpd-php") { await loadSingleMode(name);
await ensureModeLoaded("htmlmixed");
} }
await loadScriptOnce(CM_LOCAL + url); // Public helper for callers (we keep your existing function name in use):
} const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
// ==== /CodeMirror lazy loader ===============================================
function getModeForFile(fileName) { function getModeForFile(fileName) {
const dot = fileName.lastIndexOf("."); const dot = fileName.lastIndexOf(".");
@@ -215,7 +246,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>
`; `;
@@ -246,20 +277,20 @@ export function editFile(fileName, folder) {
const theme = isDarkMode ? "material-darker" : "default"; const theme = isDarkMode ? "material-darker" : "default";
const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName); const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName);
// Helper to check whether a mode is currently registered // Start core+mode loading (dont block closing)
const modeName = typeof desiredMode === "string" ? desiredMode : (desiredMode && desiredMode.name); const modePromise = (async () => {
const isModeRegistered = () => await ensureCore(); // load CM core + CSS
(window.CodeMirror?.modes && window.CodeMirror.modes[modeName]) || if (!forcePlainText) {
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[modeName]); await ensureModeLoaded(desiredMode); // then load the needed mode + deps
}
// Start mode loading (dont block closing) })();
const modePromise = ensureModeLoaded(desiredMode);
// Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available // Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available
const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS)); const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS));
Promise.race([modePromise, timeout]).then(() => { Promise.race([modePromise, timeout]).then(() => {
if (canceled) return; if (canceled) return;
if (!window.CodeMirror) { if (!window.CodeMirror) {
// Core not present: keep plain <textarea>; enable Save and bail gracefully // Core not present: keep plain <textarea>; enable Save and bail gracefully
document.getElementById("saveBtn").disabled = false; document.getElementById("saveBtn").disabled = false;
@@ -267,7 +298,9 @@ export function editFile(fileName, folder) {
return; return;
} }
const initialMode = (forcePlainText || !isModeRegistered()) ? "text/plain" : desiredMode; const normName = normalizeModeName(desiredMode) || "text/plain";
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
const cmOptions = { const cmOptions = {
lineNumbers: !forcePlainText, lineNumbers: !forcePlainText,
mode: initialMode, mode: initialMode,
@@ -319,9 +352,12 @@ export function editFile(fileName, folder) {
// 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) {
const nn = normalizeModeName(desiredMode);
if (nn && isModeRegistered(nn)) {
editor.setOption("mode", desiredMode); editor.setOption("mode", desiredMode);
} }
}
}).catch(() => { }).catch(() => {
// If the mode truly fails to load, we just stay in plain text // If the mode truly fails to load, we just stay in plain text
}); });

View File

@@ -11,11 +11,11 @@ import {
updateRowHighlight, updateRowHighlight,
toggleRowSelection, toggleRowSelection,
attachEnterKeyListener attachEnterKeyListener
} from './domUtils.js'; } from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js'; import { t } from './i18n.js?v={{APP_QVER}}';
import { bindFileListContextMenu } from './fileMenu.js'; import { bindFileListContextMenu } from './fileMenu.js?v={{APP_QVER}}';
import { openDownloadModal } from './fileActions.js'; import { openDownloadModal } from './fileActions.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js'; import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
import { import {
getParentFolder, getParentFolder,
updateBreadcrumbTitle, updateBreadcrumbTitle,
@@ -24,13 +24,13 @@ import {
hideFolderManagerContextMenu, hideFolderManagerContextMenu,
openRenameFolderModal, openRenameFolderModal,
openDeleteFolderModal openDeleteFolderModal
} from './folderManager.js'; } from './folderManager.js?v={{APP_QVER}}';
import { openFolderShareModal } from './folderShareModal.js'; import { openFolderShareModal } from './folderShareModal.js?v={{APP_QVER}}';
import { import {
folderDragOverHandler, folderDragOverHandler,
folderDragLeaveHandler, folderDragLeaveHandler,
folderDropHandler folderDropHandler
} from './fileDragDrop.js'; } from './fileDragDrop.js?v={{APP_QVER}}';
export let fileData = []; export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true }; export let sortOrder = { column: "uploaded", ascending: true };
@@ -205,28 +205,83 @@ function wireSelectAll(fileListContent) {
/** /**
* Fuse.js fuzzy search helper * Fuse.js fuzzy search helper
*/ */
// --- Lazy Fuse loader (drop-in, CSP-safe, no inline) ---
const FUSE_SRC = '/vendor/fuse/6.6.2/fuse.min.js?v={{APP_QVER}}';
let _fuseLoadingPromise = null;
function loadScriptOnce(src) {
// cache by src so we don't append multiple <script> tags
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 lazyLoadFuse() {
if (window.Fuse) return Promise.resolve(window.Fuse);
if (!_fuseLoadingPromise) {
_fuseLoadingPromise = loadScriptOnce(FUSE_SRC).then(() => window.Fuse);
}
return _fuseLoadingPromise;
}
// (Optional) warm-up call you can trigger from main.js after first render:
// import { warmUpSearch } from './fileListView.js?v={{APP_QVER}}';
// warmUpSearch();
// This just starts fetching Fuse in the background.
export function warmUpSearch() {
lazyLoadFuse().catch(() => {/* ignore; well fall back */});
}
// Lazy + backward-compatible search
function searchFiles(searchTerm) { function searchFiles(searchTerm) {
if (!searchTerm) return fileData; if (!searchTerm) return fileData;
let keys = [ // kick off Fuse load in the background, but don't await
lazyLoadFuse().catch(() => { /* ignore */ });
// keys config (matches your original)
const fuseKeys = [
{ name: 'name', weight: 0.1 }, { name: 'name', weight: 0.1 },
{ name: 'uploader', weight: 0.1 }, { name: 'uploader', weight: 0.1 },
{ name: 'tags.name', weight: 0.1 } { name: 'tags.name', weight: 0.1 }
]; ];
if (window.advancedSearchEnabled) { if (window.advancedSearchEnabled) {
keys.push({ name: 'content', weight: 0.7 }); fuseKeys.push({ name: 'content', weight: 0.7 });
} }
// If Fuse is present, use it right away (synchronous API)
if (window.Fuse) {
const options = { const options = {
keys: keys, keys: fuseKeys,
threshold: 0.4, threshold: 0.4,
minMatchCharLength: 2, minMatchCharLength: 2,
ignoreLocation: true ignoreLocation: true
}; };
const fuse = new window.Fuse(fileData, options);
const results = fuse.search(searchTerm);
return results.map(r => r.item);
}
const fuse = new Fuse(fileData, options); // Fallback (first keystrokes before Fuse finishes loading):
let results = fuse.search(searchTerm); // simple case-insensitive substring match on the same fields
return results.map(result => result.item); const q = String(searchTerm).toLowerCase();
const hay = (v) => (v == null ? '' : String(v)).toLowerCase();
return fileData.filter(item => {
if (hay(item.name).includes(q)) return true;
if (hay(item.uploader).includes(q)) return true;
if (Array.isArray(item.tags) && item.tags.some(t => hay(t?.name).includes(q))) return true;
if (window.advancedSearchEnabled && hay(item.content).includes(q)) return true;
return false;
});
} }
/** /**
@@ -750,7 +805,7 @@ function wireSelectAll(fileListContent) {
fileListContent.querySelectorAll(".edit-btn").forEach(btn => { fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
btn.addEventListener("click", async e => { btn.addEventListener("click", async e => {
e.stopPropagation(); e.stopPropagation();
const m = await import('./fileEditor.js'); const m = await import('./fileEditor.js?v={{APP_QVER}}');
m.editFile(btn.dataset.editName, btn.dataset.editFolder); m.editFile(btn.dataset.editName, btn.dataset.editFolder);
}); });
}); });
@@ -759,7 +814,7 @@ function wireSelectAll(fileListContent) {
fileListContent.querySelectorAll(".rename-btn").forEach(btn => { fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
btn.addEventListener("click", async e => { btn.addEventListener("click", async e => {
e.stopPropagation(); e.stopPropagation();
const m = await import('./fileActions.js'); const m = await import('./fileActions.js?v={{APP_QVER}}');
m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder); m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
}); });
}); });
@@ -768,7 +823,7 @@ function wireSelectAll(fileListContent) {
fileListContent.querySelectorAll(".preview-btn").forEach(btn => { fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
btn.addEventListener("click", async e => { btn.addEventListener("click", async e => {
e.stopPropagation(); e.stopPropagation();
const m = await import('./filePreview.js'); const m = await import('./filePreview.js?v={{APP_QVER}}');
m.previewFile(btn.dataset.previewUrl, btn.dataset.previewName); m.previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
}); });
}); });
@@ -822,7 +877,7 @@ function wireSelectAll(fileListContent) {
const fileName = this.getAttribute("data-file"); const fileName = this.getAttribute("data-file");
const file = fileData.find(f => f.name === fileName); const file = fileData.find(f => f.name === fileName);
if (file) { if (file) {
import('./filePreview.js').then(module => { import('./filePreview.js?v={{APP_QVER}}').then(module => {
module.openShareModal(file, folder); module.openShareModal(file, folder);
}); });
} }
@@ -831,7 +886,7 @@ function wireSelectAll(fileListContent) {
updateFileActionButtons(); updateFileActionButtons();
document.querySelectorAll("#fileList tbody tr").forEach(row => { document.querySelectorAll("#fileList tbody tr").forEach(row => {
row.setAttribute("draggable", "true"); row.setAttribute("draggable", "true");
import('./fileDragDrop.js').then(module => { import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
row.addEventListener("dragstart", module.fileDragStartHandler); row.addEventListener("dragstart", module.fileDragStartHandler);
}); });
}); });
@@ -1085,7 +1140,7 @@ function wireSelectAll(fileListContent) {
// preview clicks (dynamic import to avoid global dependency) // preview clicks (dynamic import to avoid global dependency)
fileListContent.querySelectorAll(".gallery-preview").forEach(el => { fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
el.addEventListener("click", async () => { el.addEventListener("click", async () => {
const m = await import('./filePreview.js'); const m = await import('./filePreview.js?v={{APP_QVER}}');
m.previewFile(el.dataset.previewUrl, el.dataset.previewName); m.previewFile(el.dataset.previewUrl, el.dataset.previewName);
}); });
}); });
@@ -1102,7 +1157,7 @@ function wireSelectAll(fileListContent) {
fileListContent.querySelectorAll(".edit-btn").forEach(btn => { fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
btn.addEventListener("click", async e => { btn.addEventListener("click", async e => {
e.stopPropagation(); e.stopPropagation();
const m = await import('./fileEditor.js'); const m = await import('./fileEditor.js?v={{APP_QVER}}');
m.editFile(btn.dataset.editName, btn.dataset.editFolder); m.editFile(btn.dataset.editName, btn.dataset.editFolder);
}); });
}); });
@@ -1111,7 +1166,7 @@ function wireSelectAll(fileListContent) {
fileListContent.querySelectorAll(".rename-btn").forEach(btn => { fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
btn.addEventListener("click", async e => { btn.addEventListener("click", async e => {
e.stopPropagation(); e.stopPropagation();
const m = await import('./fileActions.js'); const m = await import('./fileActions.js?v={{APP_QVER}}');
m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder); m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
}); });
}); });
@@ -1123,7 +1178,7 @@ function wireSelectAll(fileListContent) {
const fileName = btn.dataset.file; const fileName = btn.dataset.file;
const fileObj = fileData.find(f => f.name === fileName); const fileObj = fileData.find(f => f.name === fileName);
if (fileObj) { if (fileObj) {
import('./filePreview.js').then(m => m.openShareModal(fileObj, folder)); import('./filePreview.js?v={{APP_QVER}}').then(m => m.openShareModal(fileObj, folder));
} }
}); });
}); });

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

View File

@@ -1,7 +1,7 @@
// 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 { fileData } from './fileListView.js?v={{APP_QVER}}';
import { t } from './i18n.js'; import { t } from './i18n.js?v={{APP_QVER}}';
export function openShareModal(file, folder) { export function openShareModal(file, folder) {
// Remove any existing modal // Remove any existing modal

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.

View File

@@ -1,11 +1,11 @@
// folderManager.js // folderManager.js
import { loadFileList } from './fileListView.js'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js'; import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js'; import { t } from './i18n.js?v={{APP_QVER}}';
import { openFolderShareModal } from './folderShareModal.js'; import { openFolderShareModal } from './folderShareModal.js?v={{APP_QVER}}';
import { fetchWithCsrf } from './auth.js'; import { fetchWithCsrf } from './auth.js?v={{APP_QVER}}';
import { loadCsrfToken } from './main.js'; import { loadCsrfToken } from './appCore.js?v={{APP_QVER}}';
/* ---------------------- /* ----------------------
Helpers: safe JSON + state Helpers: safe JSON + state

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

File diff suppressed because it is too large Load Diff

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

View File

@@ -1,9 +1,9 @@
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 { t } from './i18n.js?v={{APP_QVER}}';
/* ----------------------------------------------------- /* -----------------------------------------------------
Helpers for DragandDrop Folder Uploads (Original Code) Helpers for DragandDrop Folder Uploads (Original Code)
@@ -36,6 +36,38 @@ function traverseFileTreePromise(item, path = "") {
}); });
} }
// --- Lazy loader for Resumable.js (no CSP inline, cached, safe) ---
const RESUMABLE_SRC = '/vendor/resumable/1.1.0/resumable.min.js?v={{APP_QVER}}';
let _resumableLoadPromise = null;
function loadScriptOnce(src) {
if (loadScriptOnce._cache?.has(src)) return loadScriptOnce._cache.get(src);
loadScriptOnce._cache = loadScriptOnce._cache || new Map();
const p = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.async = true;
s.onload = resolve;
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
loadScriptOnce._cache.set(src, p);
return p;
}
function lazyLoadResumable() {
if (window.Resumable) return Promise.resolve(window.Resumable);
if (!_resumableLoadPromise) {
_resumableLoadPromise = loadScriptOnce(RESUMABLE_SRC).then(() => window.Resumable);
}
return _resumableLoadPromise;
}
// Optional: let main.js prefetch it in the background
export function warmUpResumable() {
lazyLoadResumable().catch(() => {/* ignore warm-up failure */});
}
// Recursively retrieve files from DataTransfer items. // Recursively retrieve files from DataTransfer items.
function getFilesFromDataTransferItems(items) { function getFilesFromDataTransferItems(items) {
const promises = []; const promises = [];
@@ -401,10 +433,24 @@ 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;
// Make init async-safe; it resolves when Resumable is constructed
async function initResumableUpload() {
if (resumableInstance) return;
// Load the library if needed
const ResumableCtor = await lazyLoadResumable().catch(err => {
console.error('Failed to load Resumable.js:', err);
return null;
});
if (!ResumableCtor) return;
// Construct the instance once
if (!resumableInstance) {
resumableInstance = new ResumableCtor({
target: "/api/upload/upload.php", target: "/api/upload/upload.php",
chunkSize: 1.5 * 1024 * 1024, chunkSize: 1.5 * 1024 * 1024,
simultaneousUploads: 3, simultaneousUploads: 3,
@@ -417,20 +463,19 @@ function initResumableUpload() {
upload_token: window.csrfToken upload_token: window.csrfToken
}) })
}); });
}
// keep query fresh when folder changes (call this from your folder nav code) // keep query fresh when folder changes (call this from your folder nav code)
function updateResumableQuery() { function updateResumableQuery() {
if (!resumableInstance) return; if (!resumableInstance) return;
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken; resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
// if you're not using a function for query, do:
resumableInstance.opts.query.folder = window.currentFolder || 'root'; resumableInstance.opts.query.folder = window.currentFolder || 'root';
resumableInstance.opts.query.upload_token = window.csrfToken; resumableInstance.opts.query.upload_token = window.csrfToken;
} }
const fileInput = document.getElementById("file"); const fileInput = document.getElementById("file");
if (fileInput) { if (fileInput) {
// Assign Resumable to file input for file picker uploads.
resumableInstance.assignBrowse(fileInput);
fileInput.addEventListener("change", function () { fileInput.addEventListener("change", function () {
for (let i = 0; i < fileInput.files.length; i++) { for (let i = 0; i < fileInput.files.length; i++) {
resumableInstance.addFile(fileInput.files[i]); resumableInstance.addFile(fileInput.files[i]);
@@ -587,13 +632,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");
@@ -857,32 +913,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];
const isFolderish = !!(first.customRelativePath || first.webkitRelativePath);
if (useResumable && !isFolderish) {
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
// ensure folder/token fresh
resumableInstance.opts.query.folder = window.currentFolder || "root"; resumableInstance.opts.query.folder = window.currentFolder || "root";
resumableInstance.upload(); resumableInstance.upload();
showToast("Resumable upload started..."); showToast("Resumable upload started...");
} else {
// fallback
submitFiles(files);
}
} else { } else {
submitFiles(files); submitFiles(files);
} }

View File

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

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

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

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

File diff suppressed because one or more lines are too long

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

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

View File

@@ -8,52 +8,57 @@ class AdminController
{ {
public function getConfig(): void public function getConfig(): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json; charset=utf-8');
// Load raw config (no disclosure yet)
$config = AdminModel::getConfig(); $config = AdminModel::getConfig();
if (isset($config['error'])) { if (isset($config['error'])) {
http_response_code(500); http_response_code(500);
echo json_encode(['error' => $config['error']]); header('Cache-Control: no-store');
exit; echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
} }
// Minimal, safe subset for all callers (unauth users and regular users) // Whitelisted public subset only
$public = [ $public = [
'header_title' => $config['header_title'] ?? 'FileRise', 'header_title' => (string)($config['header_title'] ?? 'FileRise'),
'loginOptions' => [ 'loginOptions' => [
// expose only what the login page / header needs
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false), 'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false), 'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false), 'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
], ],
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '', 'globalOtpauthUrl' => (string)($config['globalOtpauthUrl'] ?? ''),
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false), 'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0), 'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
'oidc' => [ 'oidc' => [
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''), 'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''), 'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
// never expose clientId / clientSecret // never include clientId/clientSecret
], ],
]; ];
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']); $isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
if ($isAdmin) { if ($isAdmin) {
// Add admin-only fields (used by Admin Panel UI) // admin-only extras: presence flags + proxy options
$adminExtra = [ $adminExtra = [
'loginOptions' => array_merge($public['loginOptions'], [ 'loginOptions' => array_merge($public['loginOptions'], [
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false), 'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'), 'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
]), ]),
'oidc' => array_merge($public['oidc'], [
'hasClientId' => !empty($config['oidc']['clientId']),
'hasClientSecret' => !empty($config['oidc']['clientSecret']),
]),
]; ];
echo json_encode(array_merge($public, $adminExtra)); header('Cache-Control: no-store'); // dont cache admin config
echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return; return;
} }
// Non-admins / unauthenticated: only the public subset // Non-admins / unauthenticated: only the public subset
echo json_encode($public); header('Cache-Control: no-store');
echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
} }
public function updateConfig(): void public function updateConfig(): void
@@ -99,7 +104,7 @@ class AdminController
'header_title' => '', 'header_title' => '',
'loginOptions' => [ 'loginOptions' => [
'disableFormLogin' => false, 'disableFormLogin' => false,
'disableBasicAuth' => false, 'disableBasicAuth' => true,
'disableOIDCLogin' => true, 'disableOIDCLogin' => true,
'authBypass' => false, 'authBypass' => false,
'authHeaderName' => 'X-Remote-User' 'authHeaderName' => 'X-Remote-User'

View File

@@ -70,7 +70,10 @@ class AuthController
if ($oidcAction === 'callback') { if ($oidcAction === 'callback') {
try { try {
$oidc->authenticate(); $oidc->authenticate();
$username = $oidc->requestUserInfo('preferred_username'); $username =
$oidc->requestUserInfo('preferred_username')
?: $oidc->requestUserInfo('email')
?: $oidc->requestUserInfo('sub');
// check if this user has a TOTP secret // check if this user has a TOTP secret
$totp_secret = null; $totp_secret = null;

View File

@@ -53,28 +53,39 @@ class UploadController {
// ---- 3) Folder-level WRITE permission (ACL) ---- // ---- 3) Folder-level WRITE permission (ACL) ----
// Always require client to send the folder; fall back to GET if needed. // Always require client to send the folder; fall back to GET if needed.
$folderParam = isset($_POST['folder']) ? (string)$_POST['folder'] : (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root'); $folderParam = isset($_POST['folder'])
? (string)$_POST['folder']
: (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
// Decode %xx (e.g., "test%20folder") then normalize
$folderParam = rawurldecode($folderParam);
$targetFolder = ACL::normalizeFolder($folderParam); $targetFolder = ACL::normalizeFolder($folderParam);
// Admins bypass folder canWrite checks // Admins bypass folder canWrite checks
$username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms);
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) { if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
http_response_code(403); http_response_code(403);
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']); echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
return; return;
} }
// ---- 4) Delegate to model (actual file/chunk processing) ---- // ---- 4) Delegate to model (force the sanitized folder) ----
// (Optionally re-check in UploadModel before finalizing.) $_POST['folder'] = $targetFolder; // in case model reads superglobal
$result = UploadModel::handleUpload($_POST, $_FILES); $post = $_POST;
$post['folder'] = $targetFolder;
// ---- 5) Response ---- $result = UploadModel::handleUpload($post, $_FILES);
// ---- 5) Response (unchanged) ----
if (isset($result['error'])) { if (isset($result['error'])) {
http_response_code(400); http_response_code(400);
echo json_encode($result); echo json_encode($result);
return; return;
} }
if (isset($result['status'])) { if (isset($result['status'])) {
// e.g., {"status":"chunk uploaded"}
echo json_encode($result); echo json_encode($result);
return; return;
} }
@@ -101,8 +112,9 @@ class UploadController {
return; return;
} }
$folder = (string)$_POST['folder']; $folderRaw = (string)$_POST['folder'];
$result = UploadModel::removeChunks($folder); $folder = ACL::normalizeFolder(rawurldecode($folderRaw));
echo json_encode($result);
echo json_encode(UploadModel::removeChunks($folder));
} }
} }

View File

@@ -3,6 +3,7 @@
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php'; require_once PROJECT_ROOT . '/src/models/UserModel.php';
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
/** /**
* UserController * UserController
@@ -665,4 +666,38 @@ class UserController
echo json_encode(['success' => true, 'url' => $url]); echo json_encode(['success' => true, 'url' => $url]);
exit; exit;
} }
public function siteConfig(): void
{
header('Content-Type: application/json');
$usersDir = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR;
$publicPath = $usersDir . 'siteConfig.json';
$adminEncPath = $usersDir . 'adminConfig.json';
$publicMtime = is_file($publicPath) ? (int)@filemtime($publicPath) : 0;
$adminMtime = is_file($adminEncPath) ? (int)@filemtime($adminEncPath) : 0;
// If public cache is present and fresh enough, serve it
if ($publicMtime > 0 && $publicMtime >= $adminMtime) {
$raw = @file_get_contents($publicPath);
$data = is_string($raw) ? json_decode($raw, true) : null;
if (is_array($data)) {
echo json_encode($data);
return;
}
}
// Otherwise regenerate from decrypted admin config
$cfg = AdminModel::getConfig();
if (isset($cfg['error'])) {
http_response_code(500);
echo json_encode(['error' => $cfg['error']]);
return;
}
$public = AdminModel::buildPublicSubset($cfg);
$w = AdminModel::writeSiteConfig($public); // best effort
echo json_encode($public);
}
} }

View File

@@ -62,6 +62,51 @@ class AdminModel
return (int)$val; return (int)$val;
} }
public static function buildPublicSubset(array $config): array
{
return [
'header_title' => $config['header_title'] ?? 'FileRise',
'loginOptions' => [
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
// do NOT include authBypass/authHeaderName here — admin-only
],
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
'oidc' => [
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
// never include clientId / clientSecret
],
];
}
/** Write USERS_DIR/siteConfig.json atomically (unencrypted). */
public static function writeSiteConfig(array $publicSubset): array
{
$dest = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . 'siteConfig.json';
$tmp = $dest . '.tmp';
$json = json_encode($publicSubset, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
return ["error" => "Failed to encode siteConfig.json"];
}
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
return ["error" => "Failed to write temp siteConfig.json"];
}
if (!@rename($tmp, $dest)) {
@unlink($tmp);
return ["error" => "Failed to move siteConfig.json into place"];
}
@chmod($dest, 0664); // readable in bind mounts
return ["success" => true];
}
/** /**
* Updates the admin configuration file. * Updates the admin configuration file.
* *
@@ -157,6 +202,14 @@ class AdminModel
// Best-effort normalize perms for host visibility (user rw, group rw) // Best-effort normalize perms for host visibility (user rw, group rw)
@chmod($configFile, 0664); @chmod($configFile, 0664);
$public = self::buildPublicSubset($configUpdate);
$w = self::writeSiteConfig($public);
// Dont fail the whole update if public cache write had a minor issue.
if (isset($w['error'])) {
// Log but keep success for admin write
error_log("AdminModel::writeSiteConfig warning: " . $w['error']);
}
return ["success" => "Configuration updated successfully."]; return ["success" => "Configuration updated successfully."];
} }
@@ -262,7 +315,7 @@ class AdminModel
], ],
'loginOptions' => [ 'loginOptions' => [
'disableFormLogin' => false, 'disableFormLogin' => false,
'disableBasicAuth' => false, 'disableBasicAuth' => true,
'disableOIDCLogin' => true 'disableOIDCLogin' => true
], ],
'globalOtpauthUrl' => "", 'globalOtpauthUrl' => "",

View File

@@ -6,54 +6,58 @@ require_once PROJECT_ROOT . '/config/config.php';
class UploadModel { class UploadModel {
private static function sanitizeFolder(string $folder): string { private static function sanitizeFolder(string $folder): string {
$folder = trim($folder); // decode "%20", normalise slashes & trim via ACL helper
if ($folder === '' || strtolower($folder) === 'root') return ''; $f = ACL::normalizeFolder(rawurldecode($folder));
// no traversal
if (strpos($folder, '..') !== false) return ''; // model uses '' to represent root
// only safe chars + forward slashes if ($f === 'root') return '';
if (!preg_match('/^[A-Za-z0-9_\-\/]+$/', $folder)) return '';
// normalize: strip leading slashes // forbid dot segments / empty parts
return ltrim($folder, '/'); foreach (explode('/', $f) as $seg) {
if ($seg === '' || $seg === '.' || $seg === '..') {
return '';
}
} }
// allow spaces & unicode via your global regex
// (REGEX_FOLDER_NAME validates a path "seg(/seg)*")
if (!preg_match(REGEX_FOLDER_NAME, $f)) {
return '';
}
return $f; // safe, normalised, with spaces allowed
}
/**
* Handles file uploads supports both chunked uploads and full (non-chunked) uploads.
*
* @param array $post The $_POST array.
* @param array $files The $_FILES array.
* @return array Returns an associative array with "success" on success or "error" on failure.
*/
public static function handleUpload(array $post, array $files): array { public static function handleUpload(array $post, array $files): array {
// If this is a GET request for testing chunk existence. // --- GET resumable test (make folder handling consistent)
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($post['resumableTest'])) { if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($post['resumableTest'])) {
$chunkNumber = intval($post['resumableChunkNumber']); $chunkNumber = (int)($post['resumableChunkNumber'] ?? 0);
$resumableIdentifier = $post['resumableIdentifier'] ?? ''; $resumableIdentifier = $post['resumableIdentifier'] ?? '';
$folder = isset($post['folder']) ? trim($post['folder']) : 'root'; $folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
$baseUploadDir = UPLOAD_DIR; $baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') { if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR; $baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
} }
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR; $tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
$chunkFile = $tempDir . $chunkNumber; $chunkFile = $tempDir . $chunkNumber;
return ["status" => file_exists($chunkFile) ? "found" : "not found"]; return ["status" => file_exists($chunkFile) ? "found" : "not found"];
} }
// Handle chunked uploads. // --- CHUNKED ---
if (isset($post['resumableChunkNumber'])) { if (isset($post['resumableChunkNumber'])) {
$chunkNumber = intval($post['resumableChunkNumber']); $chunkNumber = (int)$post['resumableChunkNumber'];
$totalChunks = intval($post['resumableTotalChunks']); $totalChunks = (int)$post['resumableTotalChunks'];
$resumableIdentifier = $post['resumableIdentifier'] ?? ''; $resumableIdentifier = $post['resumableIdentifier'] ?? '';
$resumableFilename = urldecode(basename($post['resumableFilename'])); $resumableFilename = urldecode(basename($post['resumableFilename'] ?? ''));
// Validate file name.
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) { if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
return ["error" => "Invalid file name: $resumableFilename"]; return ["error" => "Invalid file name: $resumableFilename"];
} }
$folderRaw = $post['folder'] ?? 'root'; $folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
$folderSan = self::sanitizeFolder((string)$folderRaw);
if (empty($files['file']) || !isset($files['file']['name'])) { if (empty($files['file']) || !isset($files['file']['name'])) {
return ["error" => "No files received"]; return ["error" => "No files received"];
@@ -84,79 +88,49 @@ class UploadModel {
return ["error" => "Failed to move uploaded chunk $chunkNumber"]; return ["error" => "Failed to move uploaded chunk $chunkNumber"];
} }
// Check if all chunks are present. // all chunks present?
$allChunksPresent = true;
for ($i = 1; $i <= $totalChunks; $i++) { for ($i = 1; $i <= $totalChunks; $i++) {
if (!file_exists($tempDir . $i)) { if (!file_exists($tempDir . $i)) {
$allChunksPresent = false;
break;
}
}
if (!$allChunksPresent) {
return ["status" => "chunk uploaded"]; return ["status" => "chunk uploaded"];
} }
}
// Merge chunks. // merge
$targetPath = $baseUploadDir . $resumableFilename; $targetPath = $baseUploadDir . $resumableFilename;
if (!$out = fopen($targetPath, "wb")) { if (!$out = fopen($targetPath, "wb")) {
return ["error" => "Failed to open target file for writing"]; return ["error" => "Failed to open target file for writing"];
} }
for ($i = 1; $i <= $totalChunks; $i++) { for ($i = 1; $i <= $totalChunks; $i++) {
$chunkPath = $tempDir . $i; $chunkPath = $tempDir . $i;
if (!file_exists($chunkPath)) { if (!file_exists($chunkPath)) { fclose($out); return ["error" => "Chunk $i missing during merge"]; }
fclose($out); if (!$in = fopen($chunkPath, "rb")) { fclose($out); return ["error" => "Failed to open chunk $i"]; }
return ["error" => "Chunk $i missing during merge"]; while ($buff = fread($in, 4096)) { fwrite($out, $buff); }
}
if (!$in = fopen($chunkPath, "rb")) {
fclose($out);
return ["error" => "Failed to open chunk $i"];
}
while ($buff = fread($in, 4096)) {
fwrite($out, $buff);
}
fclose($in); fclose($in);
} }
fclose($out); fclose($out);
// Update metadata. // metadata
$metadataKey = ($folderSan === '') ? "root" : $folderSan; $metadataKey = ($folderSan === '') ? "root" : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json'; $metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName; $metadataFile = META_DIR . $metadataFileName;
$uploadedDate = date(DATE_TIME_FORMAT); $uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown"; $uploader = $_SESSION['username'] ?? "Unknown";
$metadataCollection = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : []; $collection = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_array($metadataCollection)) { if (!is_array($collection)) $collection = [];
$metadataCollection = []; if (!isset($collection[$resumableFilename])) {
} $collection[$resumableFilename] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
if (!isset($metadataCollection[$resumableFilename])) { file_put_contents($metadataFile, json_encode($collection, JSON_PRETTY_PRINT));
$metadataCollection[$resumableFilename] = [
"uploaded" => $uploadedDate,
"uploader" => $uploader
];
file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT));
} }
// Cleanup temporary directory. // cleanup temp
$rrmdir = function($dir) use (&$rrmdir) { self::rrmdir($tempDir);
if (!is_dir($dir)) return;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
$item->isDir() ? rmdir($item->getRealPath()) : unlink($item->getRealPath());
}
rmdir($dir);
};
$rrmdir($tempDir);
return ["success" => "File uploaded successfully"]; return ["success" => "File uploaded successfully"];
} else {
// Handle full upload (non-chunked)
$folderRaw = $post['folder'] ?? 'root';
$folderSan = self::sanitizeFolder((string)$folderRaw);
} }
// --- NON-CHUNKED ---
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
$baseUploadDir = UPLOAD_DIR; $baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') { if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR $baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
@@ -171,64 +145,65 @@ class UploadModel {
$metadataChanged = []; $metadataChanged = [];
foreach ($files["file"]["name"] as $index => $fileName) { foreach ($files["file"]["name"] as $index => $fileName) {
// Basic PHP upload error check per file
if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
return ["error" => "Error uploading file"]; return ["error" => "Error uploading file"];
} }
$safeFileName = trim(urldecode(basename($fileName))); $safeFileName = trim(urldecode(basename($fileName)));
if (!preg_match($safeFileNamePattern, $safeFileName)) { if (!preg_match($safeFileNamePattern, $safeFileName)) {
return ["error" => "Invalid file name: " . $fileName]; return ["error" => "Invalid file name: " . $fileName];
} }
$relativePath = ''; $relativePath = '';
if (isset($post['relativePath'])) { if (isset($post['relativePath'])) {
$relativePath = is_array($post['relativePath']) ? $post['relativePath'][$index] ?? '' : $post['relativePath']; $relativePath = is_array($post['relativePath']) ? ($post['relativePath'][$index] ?? '') : $post['relativePath'];
} }
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR; $uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
if (!empty($relativePath)) { if (!empty($relativePath)) {
$subDir = dirname($relativePath); $subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') { if ($subDir !== '.' && $subDir !== '') {
// IMPORTANT: build the subfolder under the *current* base folder $uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR . . str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
} }
$safeFileName = basename($relativePath); $safeFileName = basename($relativePath);
} }
if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) { if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) {
return ["error" => "Failed to create subfolder: " . $uploadDir]; return ["error" => "Failed to create subfolder: " . $uploadDir];
} }
$targetPath = $uploadDir . $safeFileName; $targetPath = $uploadDir . $safeFileName;
if (move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) { if (!move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
return ["error" => "Error uploading file"];
}
$metadataKey = ($folderSan === '') ? "root" : $folderSan; $metadataKey = ($folderSan === '') ? "root" : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json'; $metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName; $metadataFile = META_DIR . $metadataFileName;
if (!isset($metadataCollection[$metadataKey])) { if (!isset($metadataCollection[$metadataKey])) {
$metadataCollection[$metadataKey] = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : []; $metadataCollection[$metadataKey] = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_array($metadataCollection[$metadataKey])) { if (!is_array($metadataCollection[$metadataKey])) $metadataCollection[$metadataKey] = [];
$metadataCollection[$metadataKey] = [];
}
$metadataChanged[$metadataKey] = false; $metadataChanged[$metadataKey] = false;
} }
if (!isset($metadataCollection[$metadataKey][$safeFileName])) { if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
$uploadedDate = date(DATE_TIME_FORMAT); $uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown"; $uploader = $_SESSION['username'] ?? "Unknown";
$metadataCollection[$metadataKey][$safeFileName] = [ $metadataCollection[$metadataKey][$safeFileName] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
"uploaded" => $uploadedDate,
"uploader" => $uploader
];
$metadataChanged[$metadataKey] = true; $metadataChanged[$metadataKey] = true;
} }
} else {
return ["error" => "Error uploading file"];
}
} }
foreach ($metadataCollection as $folderKey => $data) { foreach ($metadataCollection as $folderKey => $data) {
if ($metadataChanged[$folderKey]) { if (!empty($metadataChanged[$folderKey])) {
$metadataFileName = str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json'; $metadataFileName = str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName; $metadataFile = META_DIR . $metadataFileName;
file_put_contents($metadataFile, json_encode($data, JSON_PRETTY_PRINT)); file_put_contents($metadataFile, json_encode($data, JSON_PRETTY_PRINT));
} }
} }
return ["success" => "Files uploaded successfully"]; return ["success" => "Files uploaded successfully"];
} }