Compare commits

..

57 Commits

Author SHA1 Message Date
github-actions[bot]
77a94ecd85 chore(release): set APP_VERSION to v1.7.5 [skip ci] 2025-11-02 04:56:35 +00:00
Ryan
699873848e release(v1.7.5): retrigger CI bump ensure up to date 2025-11-02 00:56:24 -04:00
Ryan
9cb12c11a6 release(v1.7.5): retrigger CI bump (no code changes) 2025-11-02 00:49:36 -04:00
Ryan
c08876380b release(v1.7.5): retrigger CI bump; chore(ci): update bump workflow 2025-11-02 00:44:29 -04:00
Ryan
5b824888cb release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50) 2025-11-02 00:32:04 -04:00
Ryan
b7d7f7c3ce release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50) 2025-11-02 00:32:03 -04:00
github-actions[bot]
e509b7ac9c chore(release): set APP_VERSION to v1.7.4 [skip ci] 2025-10-31 22:18:01 +00:00
Ryan
947255d94c release(v1.7.4): login hint replace toast + fix unauth boot 2025-10-31 18:17:52 -04:00
Ryan
55d44ef880 release(1.7.4): login hint replaced toast + fix unauth boot 2025-10-31 18:11:08 -04:00
github-actions[bot]
ad76e37ad5 chore(release): set APP_VERSION to v1.7.3 [skip ci] 2025-10-31 21:34:41 +00:00
Ryan
d664a2f5d8 release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth 2025-10-31 17:34:25 -04:00
github-actions[bot]
a18a8df7af chore(release): set APP_VERSION to v1.7.2 [skip ci] 2025-10-29 20:54:31 +00:00
Ryan
8cf5a34ae9 release(v1.7.2): harden asset stamping & CI verification 2025-10-29 16:54:22 -04:00
github-actions[bot]
55d5656139 chore(release): set APP_VERSION to v1.7.1 [skip ci] 2025-10-29 20:19:45 +00:00
Ryan
04be05ad1e release(v1.7.1): stamp-assets.sh invoke via bash 2025-10-29 16:19:35 -04:00
github-actions[bot]
0469d183de chore(release): set APP_VERSION to v1.7.0 [skip ci] 2025-10-29 20:07:32 +00:00
Ryan
b1de8679e0 release(v1.7.0): asset cache-busting pipeline, public siteConfig cache, JS core split, and caching/security polish 2025-10-29 16:07:22 -04:00
github-actions[bot]
f4f7ec0dca chore(release): set APP_VERSION and stamp assets to v1.6.11 [skip ci] 2025-10-28 07:22:04 +00:00
Ryan
5a7c4704d0 release(v1.6.11) fix(ui/dragAndDrop) restore floating zones toggle click action 2025-10-28 03:21:52 -04:00
Ryan
8b880738d6 chore(codeql): move config to repo root for default setup 2025-10-28 02:54:17 -04:00
Ryan
06c732971f ci(release): fix lint + harden release workflow 2025-10-28 02:44:13 -04:00
github-actions[bot]
ab75381acb chore(release): set APP_VERSION and stamp assets to v1.6.10 [skip ci] 2025-10-28 06:12:04 +00:00
Ryan
b1bd903072 release(v1.6.10): self-host ReDoc, gate sidebar toggle on auth, and enrich release workflow 2025-10-28 02:11:54 -04:00
Ryan
ab327acc8a chore(icons): remove material-symbols-rounded 2025-10-27 06:01:07 -04:00
Ryan
2e98ceee4c docs: move THIRD_PARTY.md to repo root 2025-10-27 05:55:05 -04:00
Ryan
3351a11927 ci(release): touch version.js to trigger release-on-version workflow 2025-10-27 05:40:36 -04:00
Ryan
4dddcf0f99 chore(ci): remove CodeQL workflow 2025-10-27 05:37:45 -04:00
Ryan
35966964e7 chore(ci,codeql): lint fixes, release trigger; stamp ?v in HTML/CSS; fix editor cache-busting 2025-10-27 05:31:01 -04:00
github-actions[bot]
7fe8e858ae chore(release): set APP_VERSION and stamp assets to v1.6.9 [skip ci] 2025-10-27 08:48:46 +00:00
Ryan
64332211c9 release(v1.6.9): feat(core) localize assets, harden headers, and speed up load 2025-10-27 04:48:31 -04:00
Ryan
3e37738e3f ci(release): touch version.js to trigger release-on-version workflow 2025-10-25 20:57:43 -04:00
Ryan
2ba33f40f8 ci(release): add workflow to auto-publish GitHub Release on version.js change 2025-10-25 20:54:49 -04:00
Ryan
badcf5c02b ci(release): add workflow to auto-publish GitHub Release on version.js updates 2025-10-25 20:51:58 -04:00
github-actions[bot]
89976f444f chore: set APP_VERSION to v1.6.8 2025-10-26 00:33:15 +00:00
Ryan
9c53c37f38 release(v1.6.8): fix(ui) prevent Extract/Create flash on refresh; remember last folder 2025-10-25 20:33:01 -04:00
Ryan
a400163dfb docs(assets): folder access screenshot 2025-10-25 15:08:46 -04:00
Ryan
ebe5939bf5 docs(assets,readme): refresh screenshots and demo video for v1.6.7 2025-10-25 14:26:36 -04:00
Ryan
83757c7470 new video demo date and link in README
Updated the video demo date and link in README.
2025-10-25 13:58:25 -04:00
github-actions[bot]
8e363ea758 chore: set APP_VERSION to v1.6.7 2025-10-25 06:16:13 +00:00
Ryan
2739925f0b release(v1.6.7): Folder Move feature, stable DnD persistence, safer uploads, and ACL/UI polish 2025-10-25 02:16:01 -04:00
github-actions[bot]
b5610cf156 chore: set APP_VERSION to v1.6.6 2025-10-24 07:21:53 +00:00
Ryan
ae932a9aa9 release(v1.6.6): header-mounted toggle, dark-mode polish, persistent layout, and ACL fix 2025-10-24 03:21:39 -04:00
github-actions[bot]
a106d47f77 chore: set APP_VERSION to v1.6.5 2025-10-24 06:12:09 +00:00
Ryan
41d464a4b3 release(v1.6.5): fix PHP warning and upload-flag check in capabilities.php 2025-10-24 02:11:55 -04:00
Ryan
9e69f19e23 chore: add YAML document start markers and fix lint warnings 2025-10-24 01:51:18 -04:00
Ryan
1df7bc3f87 ci(sync-changelog): fix YAML lint error by trimming trailing spaces and ensuring EOF newline 2025-10-24 01:46:58 -04:00
github-actions[bot]
e5f9831d73 chore: set APP_VERSION to v1.6.4 2025-10-24 05:36:43 +00:00
Ryan
553bc84404 release(v1.6.4): runtime version injection + CI bump/sync; caching tweaks 2025-10-24 01:36:30 -04:00
Ryan
88a8857a6f release(v1.6.3): drag/drop card persistence, admin UX fixes, and docs (closes #58) 2025-10-24 00:22:22 -04:00
Ryan
edefaaca36 docs(README): add additional badges 2025-10-23 03:36:27 -04:00
Ryan
ef0a8da696 chore(funding): enable GitHub Sponsors and Ko-fi links 2025-10-23 03:16:27 -04:00
Ryan
ebabb561d6 ui: fix “Change Password” button sizing in User Panel 2025-10-23 01:54:32 -04:00
Ryan
30761b6dad feat(i18n,auth): add Simplified Chinese (zh-CN) and expose in User Panel 2025-10-23 01:43:48 -04:00
Ryan
9ef40da5aa feat(ui): unified zone toggle + polished interactions for sidebar/top cards 2025-10-23 01:06:07 -04:00
Ryan
371a763fb4 docs(README): add permissions & description table 2025-10-22 21:41:08 -04:00
Ryan
ee717af750 feat(acl): granular per-folder permissions + stricter gates; WebDAV & UI aligned 2025-10-22 21:36:04 -04:00
Ryan
0ad7034a7d feat(editor): make modal non-blocking; add SRI + timeout for CodeMirror mode loads 2025-10-20 03:16:20 -04:00
102 changed files with 11814 additions and 5068 deletions

44
.gitattributes vendored
View File

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

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

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

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

@@ -0,0 +1,204 @@
---
name: Release on version.js update
on:
push:
branches: ["master"]
paths:
- public/js/version.js
workflow_run:
workflows: ["Bump version and sync Changelog to Docker Repo"]
types: [completed]
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
concurrency:
group: release-${{ github.ref }}-${{ github.sha }}
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure tags available
run: |
git fetch --tags --force --prune --quiet
- name: Read version from version.js
id: ver
shell: bash
run: |
set -euo pipefail
VER=$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")
if [[ -z "$VER" ]]; then
echo "Could not parse APP_VERSION from version.js" >&2
exit 1
fi
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "Parsed version: $VER"
- name: Skip if tag already exists
id: tagcheck
shell: bash
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/${{ steps.ver.outputs.version }}" >/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${{ steps.ver.outputs.version }} already exists. Skipping release."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
# 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'
id: notes
shell: bash
run: |
set -euo pipefail
NOTES_PATH=""
if [[ -f CHANGELOG.md ]]; then
awk '
BEGIN{found=0}
/^## / && !found {found=1}
found && /^---$/ {exit}
found {print}
' CHANGELOG.md > CHANGELOG_SNIPPET.md || true
sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' CHANGELOG_SNIPPET.md || true
if [[ -s CHANGELOG_SNIPPET.md ]]; then
NOTES_PATH="CHANGELOG_SNIPPET.md"
fi
fi
echo "path=$NOTES_PATH" >> "$GITHUB_OUTPUT"
- name: Compute previous tag (for Full Changelog link)
if: steps.tagcheck.outputs.exists == 'false'
id: prev
shell: bash
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
PREV=$(git tag --list "v*" --sort=-v:refname | grep -v -F "$VER" | head -n1 || true)
if [[ -z "$PREV" ]]; then
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
fi
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
echo "Previous tag or baseline: $PREV"
- name: Build release body (snippet + full changelog + checksum)
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
PREV="${{ steps.prev.outputs.prev }}"
REPO="${GITHUB_REPOSITORY}"
COMPARE_URL="https://github.com/${REPO}/compare/${PREV}...${VER}"
ZIP="FileRise-${VER}.zip"
SHA="${{ steps.sum.outputs.sha }}"
{
echo
if [[ -s CHANGELOG_SNIPPET.md ]]; then
cat CHANGELOG_SNIPPET.md
echo
fi
echo "## ${VER}"
echo "### Full Changelog"
echo "[${PREV} → ${VER}](${COMPARE_URL})"
echo
echo "### SHA-256 (zip)"
echo '```'
echo "${SHA} ${ZIP}"
echo '```'
} > RELEASE_BODY.md
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
with:
tag_name: ${{ steps.ver.outputs.version }}
target_commitish: ${{ github.sha }}
name: ${{ steps.ver.outputs.version }}
body_path: RELEASE_BODY.md
generate_release_notes: false
files: |
FileRise-${{ steps.ver.outputs.version }}.zip
FileRise-${{ steps.ver.outputs.version }}.zip.sha256

View File

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

View File

@@ -1,5 +1,512 @@
# Changelog
## Changes 11/2/2025 (v1.7.5)
release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50)
release(v1.7.5): retrigger CI bump (no code changes)
release(v1.7.5): retrigger CI bump ensure up to date
### Security/headers
- Tighten CSP: pin the inline pre-theme snippet with a script-src SHA-256 and keep everything else on 'self'.
- Improve cache policy for versioned assets: force 1y + immutable and add s-maxage for CDNs; also avoid HSTS redirects on local/dev hosts.
### Previews & editor
- Remove hardcoded `/uploads/` paths; always build preview URLs via the API (respects UPLOAD_DIR/ACL).
- Use the API URL for gallery prev/next and file-menu “Preview” to fix 404s on custom storage roots.
- Editor now probes size safely (HEAD → Range 0-0 fallback) before fetching, then fetches with credentials.
### Login, theming & UX polish
- Pre-theme inline boot sets `dark-mode` + background early; swap to `[hidden]`/`unhide()` instead of inline `display:none`.
- Add full-screen loading overlay with quick fade and proper color-scheme; prevent white/black flash on theme flips.
- Refactor app/login reveal flow in `main.js` (`revealAppAndHideOverlay`, `authed` path, setup wizard).
### HTML/CSS & perf
- Make Bootstrap/Styles/Roboto critical (plain `<link rel="stylesheet">`); keep fonts as true preloads; modulepreload app entry.
- Export a `__CSS_PROMISE__` from `defer-css.js` for sites that still promote preloads.
- Header logo marked `fetchpriority="high"` for faster first paint.
- Normalize dark-mode selectors to `.dark-mode` scope (admin panel, etc.).
### Manual Deploy script
- Add `scripts/filerise-deploy.sh`: idempotent rsync-based deploy with writable dirs preserved, optional Composer install, and PHP-FPM/Apache reloads.
### Notes
- If you change the inline pre-theme snippet, update the CSP hash accordingly.
---
## Changes 10/31/2025 (v1.7.4)
release(v1.7.4): login hint replace toast + fix unauth boot
main.js
- Added isDemoHost() and showLoginTip(message).
- In the unauth branch, call showLoginTip('Please log in to continue').
- Removed ensureToastReady() + showToast('please_log_in_to_continue') in the unauth path to avoid loading toast/DOM utils before auth.
---
## Changes 10/31/2025 (v1.7.3)
release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth
### 🎃 Highlights (advantages) 👻 🦇
- ⚡ Faster, cleaner boot: a lightweight **main.js** decides auth/setup before painting, avoids flicker, and wires modules exactly once.
- ♻️ Fewer duplicate actions: **request coalescer** dedupes POST/PUT/PATCH/DELETE to /api/* .
- ✅ Truthy UX: global **toast bridge** queues early toasts and normalizes misleading “not found/already exists” messages after success.
- 🔐 Smoother auth: CSRF priming/rotation + **TOTP step-up detection** across JSON & redirect paths; “Welcome back, `user`” toast once per tab.
- 🌓 Polished UI: **dark-mode persistence with system fallback**, live siteConfig title application, higher-z modals, drag auto-scroll.
- 🚀 Faster first paint & interactions: defer CodeMirror/Fuse/Resumable, promote preloaded CSS, and coalesce duplicate requests → snappier UI.
- 🧭 Admin polish: live header title preview, masked OIDC fields with **Replace** flow, and a **read-only Sponsors/Donations** section.
- 🧱 Safer & cache-smarter: opinionated .htaccess (CSP/HSTS/MIME/compression) + `?v={{APP_QVER}}` for versioned immutable assets.
### Core bootstrap (main.js) overhaul
- Early **toast bridge** (queues until domUtils is ready); expose `window.__FR_TOAST_FILTER__` for centralized rewrites/suppression.
- **Result guard + request coalescer** wrapping `fetch`:
- Dedupes same-origin `/api/*` mutating requests for ~800ms using a stable key (method + path + normalized body).
- Tracks “last OK” JSON (`success|status|result=ok`) to suppress false-negative error toasts after success.
- **Boot orchestrator** with hard guards:
- `__FR_FLAGS` (`booted`, `initialized`, `wired.*`, `bootPromise`, `entryStarted`) to prevent double init/leaks.
- **No-flicker login**: resolve `checkAuth()` + `setup` before showing UI; show login only when truly unauthenticated.
- **Heavy boot** for authed users: load i18n, `appCore.loadCsrfToken/initializeApp`, first file list, then light UI wiring.
- **Auth flow**:
- `primeCsrf()` + `<meta name="csrf-token">` management; persist token in localStorage.
- **TOTP** detection via header (`X-TOTP-Required`) & JSON (`totp_required` / `TOTP_REQUIRED`); calls `openTOTPLoginModal()`.
- **Welcome toast** once per tab via `sessionStorage.__fr_welcomed`.
- **UI/UX niceties**:
- `applySiteConfig()` updates header title & login method visibility on both login & authed screens.
- Dark-mode persistence with system fallback, proper a11y labels/icons.
- Create dropdown/menu wiring with capture-phase outside-click + ESC close; modal cancel safeties.
- Lift modals above cards (z-index), **drag auto-scroll** near viewport edges.
- Dispatch legacy `DOMContentLoaded`/`load` **once** (supports older inline handlers).
- Username label refresh for existing `.user-name-label` without injecting new DOM.
### Performance & UX changes
- CSS/first paint:
- Preload Bootstrap & app CSS; promote at DOMContentLoaded; keep inline CSS minimal.
- Add `width/height/decoding/fetchpriority` to logo to reduce layout shift.
- Search/editor/uploads:
- **fileListView.js**: lazy-load Fuse with instant substring fallback; `warmUpSearch()` hook.
- **fileEditor.js**: lazy-load CodeMirror core/theme/modes; start plain then upgrade; guard very large files gracefully.
- **upload.js**: lazy-load Resumable; resilient init; background warm-up; smarter addFile/submit; clearer toasts.
- Toast/UX:
- Install early toast bridge; queue & normalize messages; neutral “Done.” when server returns misleading errors after success.
### Correctness: uploads, paths, ACLs
- **UploadController/UploadModel**: normalize folders via `ACL::normalizeFolder(rawurldecode())`; stricter segment checks; consistent base paths; safer metadata writes; proper chunk presence/merge & temp cleanup.
### Auth hardening & resilience
- **auth.js/main.js/appCore.js**: CSRF rotate/retry (JSON then x-www-form-urlencoded fallback); robust login handling; fewer misleading error toasts.
- **AuthController**: OIDC username fallback to `email` or `sub` when `preferred_username` missing.
### Admin panel
- **adminPanel.js**:
- Live header title preview (instant update without reload).
- Masked OIDC client fields with **Replace** button; saved-value hints; only send secrets when replacing.
- **New “Sponsor / Donations” section (read-only)**:
- GitHub Sponsors → `https://github.com/sponsors/error311`
- Ko-fi → `https://ko-fi.com/error311`
- Includes **Copy** and **Open** buttons; values are fixed.
- **AdminController**: boolean for `oidc.hasClientId/hasClientSecret` to drive masked inputs.
### Security & caching (.htaccess)
- Consolidated security headers (CSP, CORP, HSTS on HTTPS), MIME types, compression (Brotli/Deflate), TRACE disable.
- Caching rules:
- HTML/version.js: no-cache; unversioned JS/CSS: 1h; unversioned static: 7d; **versioned assets `?v=`: 1y `immutable`**.
- **config.php**: remove duplicate runtime headers (now via Apache) to avoid proxy/CDN conflicts.
### Upgrade notes
- No schema changes.
- Ensure Apache modules (`headers`, `rewrite`, `brotli`/`deflate`) are available for the new .htaccess rules (fallbacks included).
- Versioned assets mean users shouldnt need a hard refresh; `?v={{APP_QVER}}` busts caches automatically.
---
## Changes 10/29/2025 (v1.7.0 & v1.7.1 & v1.7.2)
release(v1.7.0): asset cache-busting pipeline, public siteConfig cache, JS core split, and caching/security polish
### ✨ Features
- Public, non-sensitive site config cache:
- Add `AdminModel::buildPublicSubset()` and `writeSiteConfig()` to write `USERS_DIR/siteConfig.json`.
- New endpoint `public/api/siteConfig.php` + `UserController::siteConfig()` to serve the public subset (regenerates if stale).
- Frontend now reads `/api/siteConfig.php` (safe subset) instead of `/api/admin/getConfig.php`.
- Frontend module versioning:
- Replace all module imports with `?v={{APP_QVER}}` query param so the release/Docker stamper can pin exact versions.
- Add `scripts/stamp-assets.sh` to stamp `?v=` and `{{APP_VER}}/{{APP_QVER}}` in **staging** for ZIP/Docker builds.
### 🧩 Refactors
- Extract shared boot/bootstrap logic into `public/js/appCore.js`:
- CSRF helpers (`setCsrfToken`, `getCsrfToken`, `loadCsrfToken`)
- `initializeApp()`, `triggerLogout()`
- Keep `main.js` lean; wrap global `fetch` once to append/rotate CSRF.
- Update imports across JS modules to use versioned module URLs.
### 🚀 Performance
- Aggressive, safe caching for versioned assets:
- `.htaccess`: `?v=…``Cache-Control: max-age=31536000, immutable`.
- Unversioned JS/CSS short cache (1h), other static (7d).
- Eliminate duplicate `main.js` loads and tighten CodeMirror mode loading.
### 🔒 Security / Hardening
- `.htaccess`:
- Conditional HSTS only when HTTPS, add CORP and X-Permitted-Cross-Domain-Policies.
- CSP kept strict for modules, workers, blobs.
- Admin config exposure reduced to a curated subset in `siteConfig.json`.
### 🧪 CI/CD / Release
- **FileRise repo**
- `sync-changelog.yml`: keep `public/js/version.js` as source-of-truth only (no repo-wide stamping).
- `release-on-version.yml`: build **stamped** ZIP from a staging copy via `scripts/stamp-assets.sh`, verify placeholders removed, attach checksum.
- **filerise-docker repo**
- Read `VERSION`, checkout app to `app/`, run stamper inside build context before `docker buildx`, tag `latest` and `:${VERSION}`.
### 🔧 Defaults
- Sample/admin config defaults now set `disableBasicAuth: true` (safer default). Existing installations keep their current setting.
### 📂 Notable file changes
- `src/models/AdminModel.php` (+public subset +atomic write)
- `src/controllers/UserController.php` (+siteConfig action)
- `public/api/siteConfig.php` (new)
- `public/js/appCore.js` (new), `public/js/main.js` (slim, uses appCore)
- Many `public/js/*.js` import paths updated to `?v={{APP_QVER}}`
- `public/.htaccess` (caching & headers)
- `scripts/stamp-assets.sh` (new)
### ⚠️ Upgrade notes
- Ensure `USERS_DIR` is writable by web server for `siteConfig.json`.
- Proxies/edge caches: the new `?v=` scheme enables long-lived immutable caching; purge is automatic on version bump.
- If you previously read admin config directly on the client, it now reads `/api/siteConfig.php`.
### Additional changes/fixes for release
- `release-on-version.yml`
- normalize line endings (strip CRLF)
- stamp-assets.sh dont rely on the exec; invoke via bash
release(v1.7.2): harden asset stamping & CI verification
### build(stamper)
- Rewrite scripts/stamp-assets.sh to be repo-agnostic and macOS/Windows friendly:
- Drop reliance on git ls-files/mapfile; use find + null-delimited loops
- Normalize CRLF to LF for all web assets before stamping
- Stamp ?v=<APP_QVER> in HTML/CSS/PHP and {{APP_VER}} everywhere
- Normalize any ".mjs|.js?v=..." occurrences inside JS (ESM imports/strings)
- Force-write public/js/version.js from VER (source of truth in stamped output)
- Print touched counts and fail fast if any {{APP_QVER}}|{{APP_VER}} remain
---
## Changes 10/28/2025 (v1.6.11)
release(v1.6.11) fix(ui/dragAndDrop) restore floating zones toggle click action
Re-add the click handler to toggle `zonesCollapsed` so the header
“sidebarToggleFloating” button actually expands/collapses the zones
again. This regressed in v1.6.10 during auth-gating refactor.
Refs: #regression #ux
chore(codeql): move config to repo root for default setup
- Relocate .github/codeql/codeql-config.yml to codeql-config.yml so GitHub default code scanning picks it up
- Keep paths: public/js, api
- Keep ignores: public/vendor/**, public/css/vendor/**, public/fonts/**, public/**/*.min.{js,css}, public/**/*.map
---
## Changes 10/28/2025 (v1.6.10)
release(v1.6.10): self-host ReDoc, gate sidebar toggle on auth, and enrich release workflow
- Vendor ReDoc and add MIT license file under public/vendor/redoc/; switch api.php to local bundle to satisfy CSP (script-src 'self').
- main.js: add/remove body.authenticated on login/logout so UI can reflect auth state.
- dragAndDrop.js: only render sidebarToggleFloating when authenticated; stop event bubbling, keep dark-mode styles.
- sync-changelog.yml: also stamp ?v= in PHP templates (public/**/*.php).
- release-on-version.yml: build zip first, compute SHA-256, assemble release body with latest CHANGELOG snippet, “Full Changelog” compare link, and attach .sha256 alongside the zip.
- THIRD_PARTY.md: document ReDoc vendoring and rationale.
Refs: #security #csp #release
---
## Changes 10/27/2025 (v1.6.9)
release(v1.6.9): feat(core) localize assets, harden headers, and speed up load
- index.html: drop all CDNs in favor of local /vendor assets
- add versioned cache-busting query (?v=…) on CSS/JS
- wire version.js for APP_VERSION and numeric cache key
- public/vendor/: add pinned copies of:
- bootstrap 4.5.2, codemirror 5.65.5 (+ themes/modes), dompurify 2.4.0,
fuse.js 6.6.2, resumable.js 1.1.0
- fonts: add self-hosted Material Icons + Roboto (latin + latin-ext) with
vendor CSS (material-icons.css, roboto.css)
- fileEditor.js: load CodeMirror modes from local vendor with ?v=APP_VERSION_NUM,
keep timeout/plain-text fallback, no SRI (same-origin)
- dragAndDrop.js: nudge zonesToggle 65px left to sit tighter to the logo
- styles.css: prune/organize rules and add small utility classes; move 3P
font CSS to /css/vendor/
- .htaccess: security + performance overhaul
- Content-Security-Policy: default-src 'self'; img-src include data: and blob:
- version-aware caching: HTML/version.js = no-cache; assets with ?v= = 1y immutable
- correct MIME for fonts/SVG; enable Brotli/Gzip (if available)
- X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS, Permissions-Policy
- disable TRACE; deny dotfiles; prevent directory listing
- .gitattributes: mark vendor/minified as linguist-vendored, treat assets as
binary in diffs, exclude CI/resources from source archives
- docs/licensing:
- add licenses/ and THIRD_PARTY.md with upstream licenses/attribution
- README: add “License & Credits” section with components and licenses
- CI: (sync-changelog) stamp asset cache-busters to the numeric release
(e.g. ?v=1.6.9) and write window.APP_VERSION in version.js before Docker build
perf: site loads significantly faster with local assets + compression + long-lived caching
security: CSP, strict headers, and same-origin assets reduce XSS/SRI/CORS risk
Refs: #performance #security
---
## Changes 10/25/2025 (v1.6.8)
release(v1.6.8): fix(ui) prevent Extract/Create flash on refresh; remember last folder
- Seed `currentFolder` from `localStorage.lastOpenedFolder` (fallback to "root")
- Stop eager `loadFileList('root')` on boot; defer initial load to resolved folder
- Hide capability-gated actions by default (`#extractZipBtn`, `#createBtn`) to avoid pre-auth flash
- Eliminates transient root state when reloading inside a subfolder
User-visible: refreshing a non-root folder no longer flashes Root items or privileged buttons; app resumes in the last opened folder.
---
## Changes 10/25/2025 (v1.6.7)
release(v1.6.7): Folder Move feature, stable DnD persistence, safer uploads, and ACL/UI polish
### 📂 Folder Move (new major feature)
**Drag & Drop to move folder, use context menu or Move Folder button**
- Added **Move Folder** support across backend and UI.
- New API endpoint: `public/api/folder/moveFolder.php`
- Controller and ACL updates to validate scope, ownership, and permissions.
- Non-admins can only move within folders they own.
- `ACL::renameTree()` re-keys all subtree ACLs on folder rename/move.
- Introduced new capabilities:
- `canMoveFolder`
- `canMove` (UI alias for backward compatibility)
- New “Move Folder” button + modal in the UI with full i18n strings (`i18n.js`).
- Action button styling and tooltip consistency for all folder actions.
### 🧱 Drag & Drop / Layout Improvements
- Fixed **random sidebar → top zone jumps** on refresh.
- Cards/panels now **persist exactly where you placed them** (`userZonesSnapshot`)
— no unwanted repositioning unless the window is resized below the small-screen threshold.
- Added hysteresis around the 1205 px breakpoint to prevent flicker when resizing.
- Eliminated the 50 px “ghost” gutter with `clampSidebarWhenEmpty()`:
- Sidebar no longer reserves space when collapsed or empty.
- Temporarily “unclamps” during drag so drop targets remain accurate and full-width.
- Removed forced 800 px height on drag highlight; uses natural flex layout now.
- General layout polish — smoother transitions when toggling *Hide/Show Panels*.
### ☁️ Uploads & UX
- Stronger folder sanitization and safer base-path handling.
- Fixed subfolder creation when uploading directories (now builds under correct parent).
- Improved chunk error handling and metadata key correctness.
- Clearer success/failure toasts and accurate filename display from server responses.
### 🔐 Permissions / ACL
- Simplified file rename checks — now rely solely on granular `ACL::canRename()`.
- Updated capability lists to include move/rename operations consistently.
### 🌐 UI / i18n Enhancements
- Added i18n strings for new “Move Folder” prompts, modals, and tooltips.
- Minor UI consistency tweaks: button alignment, focus states, reduced-motion support.
---
## Changes 10/24/2025 (v1.6.6)
release(v1.6.6): header-mounted toggle, dark-mode polish, persistent layout, and ACL fix
- dragAndDrop: mount zones toggle beside header logo (absolute, non-scrolling);
stop click propagation so it doesnt trigger the logo link; theme-aware styling
- live updates via MutationObserver; snapshot card locations on drop and restore
on load (prevents sidebar reset); guard first-run defaults with
`layoutDefaultApplied_v1`; small/medium layout tweaks & refactors.
- CSS: switch toggle icon to CSS variable (`--toggle-icon-color`) with dark-mode
override; remove hardcoded `!important`.
- API (capabilities.php): remove unused `disableUpload` flag from `canUpload`
and flags payload to resolve undefined variable warning.
---
## Changes 10/24/2025 (v1.6.5)
release(v1.6.5): fix PHP warning and upload-flag check in capabilities.php
- Fix undefined variable: use $disableUpload consistently
- Harden flag read: (bool)($perms['disableUpload'] ?? false)
- Prevents warning and ensures Upload capability is computed correctly
---
## Changes 10/24/2025 (v1.6.4)
release(v1.6.4): runtime version injection + CI bump/sync; caching tweaks
- Add public/js/version.js (default "dev") and load it before main.js.
- adminPanel.js: replace hard-coded string with `window.APP_VERSION || "dev"`.
- public/.htaccess: add no-cache for js/version.js
- GitHub Actions: replace sync job with “Bump version and sync Changelog to Docker Repo”.
- Parse commit msg `release(vX.Y.Z)` -> set step output `version`.
- Write `public/js/version.js` with `window.APP_VERSION = '<version>'`.
- Commit/push version.js if changed.
- Mirror CHANGELOG.md to filerise-docker and write a VERSION file with `<version>`.
- Guard all steps with `if: steps.ver.outputs.version != ''` to no-op on non-release commits.
This wires the UI version label to CI, keeps dev builds showing “dev”, and feeds the Docker repo with CHANGELOG + VERSION for builds.
---
## Changes 10/24/2025 (v1.6.3)
release(v1.6.3): drag/drop card persistence, admin UX fixes, and docs (closes #58)
Drag & Drop - Upload/Folder Management Cards layout
- Persist panel locations across refresh; snapshot + restore when collapsing/expanding.
- Unified “zones” toggle; header-icon mode no longer loses card state.
- Responsive: auto-move sidebar cards to top on small screens; restore on resize.
- Better top-zone placeholder/cleanup during drag; tighter header modal sizing.
- Safer order saving + deterministic placement for upload/folder cards.
Admin Panel Folder Access
- Fix: newly created folders now appear without a full page refresh (cache-busted `getFolderList`).
- Show admin users in the list with full access pre-applied and inputs disabled (read-only).
- Skip sending updates for admins when saving grants.
- “Folder” column now has its own horizontal scrollbar so long names / “Inherited from …” are never cut off.
Admin Panel User Permissions (flags)
- Show admins (marked as Admin) with all switches disabled; exclude from save payload.
- Clarified helper text (account-level vs per-folder).
UI/Styling
- Added `.folder-cell` scroller in ACL table; improved dark-mode scrollbar/thumb.
Docs
- README edits:
- Clarified PUID/PGID mapping and host/NAS ownership requirements for mounted volumes.
- Environment variables section added
- CHOWN_ON_START additional details
- Admin details
- Upgrade section added
- 💖 Sponsor FileRise section added
---
## Changes 10/23/2025 (v1.6.2)
feat(i18n,auth): add Simplified Chinese (zh-CN) and expose in User Panel
- Add zh-CN locale to i18n.js with full key set.
- Introduce chinese_simplified label key across locales.
- Added some missing labels
- Update language selector mapping to include zh-CN (English/Spanish/French/German/简体中文).
- Wire zh-CN into Auth/User Panel (authModals) language dropdown.
- Fallback-safe rendering for language names when a key is missing.
ui: fix “Change Password” button sizing in User Panel
- Keep consistent padding and font size for cleaner layout
---
## Changes 10/23/2025 (v1.6.1)
feat(ui): unified zone toggle + polished interactions for sidebar/top cards
- Add floating toggle button styling (hover lift, press, focus ring, ripple)
for #zonesToggleFloating and #sidebarToggleFloating (CSS).
- Ensure icons are visible and centered; enforce consistent sizing/color.
- Introduce unified “zones collapsed” state persisted via `localStorage.zonesCollapsed`.
- Update dragAndDrop.js to:
- manage a single floating toggle for both Sidebar and Top Zone
- keep toggle visible when cards are in Top Zone; hide only when both cards are in Header
- rotate icon 90° when both cards are in Top Zone and panels are open
- respect collapsed state during DnD flows and on load
- preserve original DnD behaviors and saved orders (sidebar/header)
- Minor layout/visibility fixes during drag (clear temp heights; honor collapsed).
Notes:
- No breaking API changes; existing `sidebarOrder` / `headerOrder` continue to work.
- New key: `zonesCollapsed` (string '0'/'1') controls visibility of Sidebar + Top Zone.
UX:
- Floating toggle feels more “material”: subtle hover elevation, press feedback,
focus ring, and click ripple to restore the prior interactive feel.
- Icons remain legible on white (explicit color set), centered in the circular button.
---
## Changes 10/22/2025 (v1.6.0)
feat(acl): granular per-folder permissions + stricter gates; WebDAV & UI aligned
- Add granular ACL buckets: create, upload, edit, rename, copy, move, delete, extract, share_file, share_folder
- Implement ACL::canX helpers and expand upsert/explicit APIs (preserve read_own)
- Enforce “write no longer implies read” in canRead; use granular gates for write-ish ops
- WebDAV: use canDelete for DELETE, canUpload/canEdit + disableUpload for PUT; enforce ownership on overwrite
- Folder create: require Manage/Owner on parent; normalize paths; seed ACL; rollback on failure
- FileController: refactor copy/move/rename/delete/extract to granular gates + folder-scope checks + own-only ownership enforcement
- Capabilities API: compute effective actions with scope + readOnly/disableUpload; protect root
- Admin Panel (v1.6.0): new Folder Access editor with granular caps, inheritance hints, bulk toggles, and UX validations
- getFileList: keep root visible but inert for users without visibility; apply own-only filtering server-side
- Bump version to v1.6.0
---
## Changes 10/20/2025 (v1.5.3)
security(acl): enforce folder-scope & own-only; fix file list “Select All”; harden ops
@@ -36,6 +543,17 @@ feat(dnd): default cards to sidebar on medium screens when no saved layout
- Preserves existing sidebarOrder/headerOrder and small-screen behavior
- Keeps user changes persistent; no override once a layout exists
feat(editor): make modal non-blocking; add SRI + timeout for CodeMirror mode loads
- Build the editor modal immediately and wire close (✖, Close button, and Esc) before any async work, so the UI is always dismissible.
- Restore MODE_URL and add normalizeModeName() to resolve aliases (text/html → htmlmixed, php → application/x-httpd-php).
- Add SRI for each lazily loaded mode (MODE_SRI) and apply integrity/crossOrigin on script tags; switch to async and improved error messages.
- Introduce MODE_LOAD_TIMEOUT_MS=2500 and Promise.race() to init in text/plain if a mode is slow; auto-upgrade to the real mode once it arrives.
- Graceful fallback: if CodeMirror core isnt present, keep textarea, enable Save, and proceed.
- Minor UX: disable Save until the editor is ready, support theme toggling, better resize handling, and font size controls without blocking.
Security: Locks CDN mode scripts with SRI.
---
## Changes 10/19/2025 (v1.5.2)

131
README.md
View File

@@ -7,17 +7,25 @@
[![Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://demo.filerise.net)
[![Release](https://img.shields.io/github/v/release/error311/FileRise?include_prereleases&sort=semver)](https://github.com/error311/FileRise/releases)
[![License](https://img.shields.io/github/license/error311/FileRise)](LICENSE)
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-❤-red)](https://github.com/sponsors/error311)
[![Support on Ko-fi](https://img.shields.io/badge/Ko--fi-Buy%20me%20a%20coffee-orange)](https://ko-fi.com/error311)
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting)
**Elevate your File Management** A modern, self-hosted web file manager.
Upload, organize, and share files or folders through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
**Elevate your File Management** A modern, self-hosted web file manager.
Upload, organize, and share files or folders through a sleek, responsive web interface.
**FileRise** is lightweight yet powerful — your personal cloud drive that you fully control.
Now featuring **Granular Access Control (ACL)** with per-folder permissions, inheritance, and live admin editing.
Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage* on a per-user, per-folder basis — enforced across the UI, API, and WebDAV.
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If youre on ≤1.4.x, please upgrade.
**4/3/2025 Video demo:**
**10/25/2025 Video demo:**
<https://github.com/user-attachments/assets/221f6a53-85f5-48d4-9abe-89445e0af90e>
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
**Dark mode:**
![Dark Header](https://raw.githubusercontent.com/error311/FileRise/refs/heads/master/resources/dark-header.png)
@@ -26,33 +34,57 @@ Upload, organize, and share files or folders through a sleek web interface. **Fi
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with pause/resumable chunked uploads and shows real-time progress for each file. FileRise will pick up where it left off if your connection drops.
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with resumable chunked uploads, pause/resume, and real-time progress. If your connection drops, FileRise resumes automatically.
- 🗂️ **File Management:** Full set of file/folder operations move or copy files (via intuitive drag-drop or dialogs), rename items, and delete in batches. You can download selected files as a ZIP archive or extract uploaded ZIP files server-side. Organize content with an interactive folder tree and breadcrumb navigation for quick jumps.
- 🗂️ **File Management:** Full suite of operations move/copy (via drag-drop or dialogs), rename, and batch delete. Download selected files as ZIPs or extract uploaded ZIPs server-side. Organize with an interactive folder tree and breadcrumbs for instant navigation.
- 🗃️ **Folder Sharing & File Sharing:** Share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items/page); file sizes are displayed in MB. Share individual files with one-time or expiring links (optional password protection).
- 🗃️ **Folder & File Sharing:** Share folders or individual files with expiring, optionally password-protected links. Shared folders can accept external uploads (if enabled). Listings are paginated (10 items/page) with file sizes shown in MB.
- 🔐 **Fine-grained Access Control (ACL):** Per-folder grants for **owners**, **read** (view all), **read_own** (own-only visibility), **write** (upload/edit), and **share**.
- _Note:_ **write no longer implies read**. Grant **read** if uploaders should see all files; or **read_own** for self-only listings.
- Enforced server-side across UI, API, and WebDAV. Includes an admin UI for bulk editing (atomic updates) and safe defaults.
- 🔐 **Granular Access Control (ACL):**
Per-folder permissions for **owners**, **view**, **view (own)**, **write**, **manage**, **share**, and extended granular capabilities.
Each grant controls specific actions across the UI, API, and WebDAV:
- 🔌 **WebDAV Support (ACL-aware):** Mount FileRise as a network drive **or use it headless from the CLI**. Standard WebDAV ops (upload / download / delete) work in Cyberduck, WinSCP, GNOME Files, Finder, etc., and you can script with `curl`. Listings require **read**; users with **read_own** only see their own files; writes require **write**.
| Permission | Description |
|-------------|-------------|
| **Manage (Owner)** | Full control of folder and subfolders. Can edit ACLs, rename/delete/create folders, and share items. Implies all other permissions for that folder and below. |
| **View (All)** | Allows viewing all files within the folder. Required for folder-level sharing. |
| **View (Own)** | Restricts visibility to files uploaded by the user only. Ideal for drop zones or limited-access users. |
| **Write** | Grants general write access — enables renaming, editing, moving, copying, deleting, and extracting files. |
| **Create** | Allows creating subfolders. Automatically granted to *Manage* users. |
| **Upload** | Allows uploading new files without granting full write privileges. |
| **Edit / Rename / Copy / Move / Delete / Extract** | Individually toggleable granular file operations. |
| **Share File / Share Folder** | Controls sharing capabilities. Folder shares require full View (All). |
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
- **Automatic Propagation:** Enabling **Manage** on a folder applies to all subfolders; deselecting subfolder permissions overrides inheritance in the UI.
- 📝 **Built-in Editor & Preview:** View images, videos, audio, and PDFs inline with a preview modal. Edit text/code files in your browser with a CodeMirror-based editor with syntax highlighting and line numbers.
ACL enforcement is centralized and atomic across:
- **Admin Panel:** Interactive ACL editor with batch save and dynamic inheritance visualization.
- **API Endpoints:** All file/folder operations validate server-side.
- **WebDAV:** Uses the same ACL engine — View / Own determine listings, granular permissions control upload/edit/delete/create.
- 🏷️ **Tags & Search:** Categorize your files with color-coded tags and locate them instantly using indexed real-time search. **Advanced Search** adds fuzzy matching across file names, tags, uploader fields, and within text file contents.
- 🔌 **WebDAV (ACL-Aware):** Mount FileRise as a drive (Cyberduck, WinSCP, Finder, etc.) or access via `curl`.
- Listings require **View** or **View (Own)**.
- Uploads require **Upload**.
- Overwrites require **Edit**.
- Deletes require **Delete**.
- Creating folders requires **Create** or **Manage**.
- All ACLs and ownership rules are enforced exactly as in the web UI.
- 🔒 **Auth & SSO:** Username/password login, optional TOTP 2FA, and OIDC (Google/Authentik/Keycloak). Per-user flags like **readOnly**/**disableUpload** still supported, but folder access is governed by the ACL above.
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) with interactive HTML docs (`api.html`) via Redoc.
- 🗑️ **Trash & Recovery:** Deleted items go to Trash first; **admins** can restore or empty. Old trash entries auto-purge (default 3 days).
- 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers.
- 🎨 **Responsive UI (Dark/Light Mode):** Mobile-friendly layout with theme toggle. The interface remembers your preferences (layout, items per page, last visited folder, etc.).
- 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents.
- 🌐 **Internationalization & Localization:** Switch languages via the UI (English, Spanish, French, German). Contributions welcome.
- 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak).
- **Lightweight & Self-Contained:** Runs on PHP **8.3+** with no external database. Single-folder install or Docker image. Low footprint; scales to thousands of files with pagination and sorting.
- 🗑 **Trash & Recovery:** Deleted items move to Trash for recovery (default 3-day retention). Admins can restore or purge globally.
- 🎨 **Responsive UI (Dark/Light Mode):** Modern, mobile-friendly design with persistent preferences (theme, layout, last folder, etc.).
- 🌐 **Internationalization:** English, Spanish, French, German & Simplified Chinese available. Community translations welcome.
- ⚙️ **Lightweight & Self-Contained:** Runs on PHP 8.3+, no external DB required. Single-folder or Docker deployment with minimal footprint, optimized for Unraid and self-hosting.
(For full features and changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [CHANGELOG](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or [Releases](https://github.com/error311/FileRise/releases).)
@@ -73,6 +105,22 @@ Deploy FileRise using the **Docker image** (quickest) or a **manual install** on
---
### Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `TIMEZONE` | `UTC` | PHP/app timezone. |
| `DATE_TIME_FORMAT` | `m/d/y h:iA` | Display format used in UI. |
| `TOTAL_UPLOAD_SIZE` | `5G` | Max combined upload per request (resumable). |
| `SECURE` | `false` | Set `true` if served behind HTTPS proxy (affects link generation). |
| `PERSISTENT_TOKENS_KEY` | *(required)* | Secret for “Remember Me” tokens. Change from the example! |
| `PUID` / `PGID` | `1000` / `1000` | Map `www-data` to host uid:gid (Unraid: often `99:100`). |
| `CHOWN_ON_START` | `true` | First run: try to chown mounted dirs to PUID:PGID. |
| `SCAN_ON_START` | `true` | Reindex files added outside UI at boot. |
| `SHARE_URL` | *(blank)* | Override base URL for share links; blank = auto-detect. |
---
### 1) Running with Docker (Recommended)
#### Pull the image
@@ -91,7 +139,7 @@ docker run -d \
-e DATE_TIME_FORMAT="m/d/y h:iA" \
-e TOTAL_UPLOAD_SIZE="5G" \
-e SECURE="false" \
-e PERSISTENT_TOKENS_KEY="please_change_this_@@" \
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
-e PUID="1000" \
-e PGID="1000" \
-e CHOWN_ON_START="true" \
@@ -103,6 +151,8 @@ docker run -d \
error311/filerise-docker:latest
```
The app runs as www-data mapped to PUID/PGID. Ensure your mounted uploads/, users/, metadata/ are owned by PUID:PGID (e.g., chown -R 1000:1000 …), or set PUID/PGID to match existing host ownership (e.g., 99:100 on Unraid). On NAS/NFS, apply the ownership change on the host/NAS.
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
**Notes**
@@ -125,10 +175,10 @@ docker exec -it filerise id www-data
Save as `docker-compose.yml`, then `docker-compose up -d`:
```yaml
version: "3"
services:
filerise:
image: error311/filerise-docker:latest
container_name: filerise
ports:
- "8080:80"
environment:
@@ -136,7 +186,7 @@ services:
DATE_TIME_FORMAT: "m/d/y h:iA"
TOTAL_UPLOAD_SIZE: "10G"
SECURE: "false"
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
# Ownership & indexing
PUID: "1000" # Unraid users often use 99
PGID: "1000" # Unraid users often use 100
@@ -148,11 +198,14 @@ services:
- ./uploads:/var/www/uploads
- ./users:/var/www/users
- ./metadata:/var/www/metadata
restart: unless-stopped
```
Access at `http://localhost:8080` (or your servers IP).
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string.
-`CHOWN_ON_START=true` attempts to align ownership **inside the container**; if the host/NAS disallows changes, set the correct UID/GID on the host.”
**First-time Setup**
On first launch, if no users exist, youll be prompted to create an **Admin account**. Then use **User Management** to add more users.
@@ -217,6 +270,13 @@ Browse to your FileRise URL; youll be prompted to create the Admin user on fi
---
### 3) Admins
> **Admins in ACL UI**
> Admin accounts appear in the Folder Access and User Permissions modals as **read-only** with full access implied. This is by design—admins always have full control and are excluded from save payloads.
---
## Unraid
- Install from **Community Apps** → search **FileRise**.
@@ -226,6 +286,16 @@ Browse to your FileRise URL; youll be prompted to create the Admin user on fi
---
## Upgrade
```bash
docker pull error311/filerise-docker:latest
docker stop filerise && docker rm filerise
# re-run with the same -v and -e flags you used originally
```
---
## Quick-start: Mount via WebDAV
Once FileRise is running, enable WebDAV in the admin panel.
@@ -306,6 +376,17 @@ If you like FileRise, a ⭐ star on GitHub is much appreciated!
---
## 💖 Sponsor FileRise
If FileRise saves you time (or sparks joy 😄), please consider supporting ongoing development:
- ❤️ [**GitHub Sponsors:**](https://github.com/sponsors/error311) recurring or one-time - helps fund new features and docs.
- ☕ [**Ko-fi:**](https://ko-fi.com/error311) buy me a coffee.
Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
---
## Community and Support
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) (Announcement and user feedback thread).
@@ -343,6 +424,10 @@ If you like FileRise, a ⭐ star on GitHub is much appreciated!
---
## License
## License & Credits
MIT License see [LICENSE](LICENSE).
This project bundles third-party assets such as Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, and Google Fonts (Roboto, Material Icons).
All third-party code and fonts remain under their original open-source licenses (MIT or Apache 2.0).
See THIRD_PARTY.md and the /licenses directory for full license texts and attributions.

47
THIRD_PARTY.md Normal file
View File

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

12
codeql-config.yml Normal file
View File

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

View File

@@ -1,22 +1,6 @@
<?php
// 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('PROJECT_ROOT', dirname(__DIR__));
define('UPLOAD_DIR', '/var/www/uploads/');
@@ -40,6 +24,7 @@ if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true);
if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
define('ACL_INHERIT_ON_CREATE', true);
// Encryption helpers
function encryptData($data, $encryptionKey)

View File

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

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

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

19
licenses/mit.txt Normal file
View File

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

View File

@@ -1,75 +1,109 @@
# -----------------------------
# 1) Prevent directory listings
# -----------------------------
# --------------------------------
# Base: safe in most environments
# --------------------------------
Options -Indexes
# -----------------------------
# Default index files
# -----------------------------
DirectoryIndex index.html
# -----------------------------
# Deny access to hidden files
# -----------------------------
<FilesMatch "^\.">
Require all denied
</FilesMatch>
<IfModule mod_authz_core.c>
<FilesMatch "^\.">
Require all denied
</FilesMatch>
</IfModule>
# -----------------------------
# Enforce HTTPS (optional)
# -----------------------------
RewriteEngine On
# Never redirect local/dev hosts
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
RewriteRule ^ - [L]
# --- HTTPS redirect ---
# Use ONE of these blocks.
# A) Direct TLS on this server (enable this if Apache terminates HTTPS here)
#RewriteCond %{HTTPS} off
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
<IfModule mod_headers.c>
# Allow requests from a specific origin
#Header set Access-Control-Allow-Origin "https://demo.filerise.net"
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
Header set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With, X-CSRF-Token"
Header set Access-Control-Allow-Credentials "true"
# 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>
AddType font/woff2 .woff2
AddType font/woff .woff
AddType image/svg+xml .svg
AddType application/javascript .mjs
</IfModule>
# --- Security headers ---
<IfModule mod_headers.c>
# Prevent clickjacking
Header always set X-Frame-Options "SAMEORIGIN"
# Block XSS
Header always set X-XSS-Protection "1; mode=block"
# No MIME sniffing
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
Header always set X-Download-Options "noopen"
Header always set Expect-CT "max-age=86400, enforce"
Header always set Cross-Origin-Resource-Policy "same-origin"
Header always set X-Permitted-Cross-Domain-Policies "none"
# HSTS only when 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' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
</IfModule>
# --- Caching (query-string based, no env vars needed) ---
<IfModule mod_headers.c>
# HTML: always revalidate
<FilesMatch "\.(html|htm)$">
# HTML/PHP: no cache (only if PHP didnt already set it)
<FilesMatch "\.(html?|php)$">
Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
Header setifempty Pragma "no-cache"
Header setifempty Expires "0"
</FilesMatch>
# version.js: always non-cacheable
<FilesMatch "^js/version\.js$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
# JS/CSS: shortterm cache, revalidate regularly
<FilesMatch "\.(js|css)$">
Header set Cache-Control "public, max-age=3600, must-revalidate"
# Unversioned JS/CSS: 1 hour
<FilesMatch "\.(?:m?js|css)$">
Header set Cache-Control "public, max-age=3600, must-revalidate" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
</FilesMatch>
# Unversioned static (images/fonts): 7 days
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
Header set Cache-Control "public, max-age=604800" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
</FilesMatch>
# --- Versioned assets (?v=...) : 1 year + immutable (override anything else) ---
<IfModule mod_headers.c>
<FilesMatch "\.(?:m?js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
# Only when query string has v=
Header unset Cache-Control "expr=%{QUERY_STRING} =~ /(^|&)v=/"
Header unset Expires "expr=%{QUERY_STRING} =~ /(^|&)v=/"
Header set Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/"
</FilesMatch>
</IfModule>
</IfModule>
# -----------------------------
# Additional Security Headers
# -----------------------------
<IfModule mod_headers.c>
# Enforce HTTPS for a year with subdomains and preload option.
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Set a Referrer Policy.
Header always set Referrer-Policy "strict-origin-when-cross-origin"
# Permissions Policy: disable features you don't need.
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
# IE-specific header to prevent downloads from opening in IE.
Header always set X-Download-Options "noopen"
# Expect-CT header for Certificate Transparency (optional).
Header always set Expect-CT "max-age=86400, enforce"
# --- Compression ---
<IfModule mod_brotli.c>
BrotliCompressionQuality 5
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
</IfModule>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
</IfModule>
# -----------------------------
# Disable TRACE method
# -----------------------------
# --- Disable TRACE ---
RewriteCond %{REQUEST_METHOD} ^TRACE
RewriteRule .* - [F]

View File

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

View File

@@ -1,28 +1,5 @@
<?php
// public/api/admin/acl/getGrants.php
/**
* @OA\Get(
* path="/api/admin/acl/getGrants.php",
* summary="Get ACL grants for a user",
* tags={"Admin","ACL"},
* security={{"cookieAuth":{}}},
* @OA\Parameter(name="user", in="query", required=true, @OA\Schema(type="string")),
* @OA\Response(
* response=200,
* description="Map of folder → grant flags",
* @OA\JsonContent(
* type="object",
* required={"grants"},
* @OA\Property(property="grants", ref="#/components/schemas/GrantsMap")
* )
* ),
* @OA\Response(response=400, description="Invalid user"),
* @OA\Response(response=401, description="Unauthorized")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../config/config.php';
@@ -32,7 +9,6 @@ require_once PROJECT_ROOT . '/src/models/FolderModel.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json');
// Admin only
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit;
}
@@ -55,7 +31,7 @@ try {
} catch (Throwable $e) { /* ignore */ }
if (empty($folders)) {
$aclPath = META_DIR . 'folder_acl.json';
$aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json';
if (is_file($aclPath)) {
$data = json_decode((string)@file_get_contents($aclPath), true);
if (is_array($data['folders'] ?? null)) {
@@ -74,29 +50,36 @@ $has = function(array $arr, string $u): bool {
$out = [];
foreach ($folderList as $f) {
$rec = ACL::explicit($f); // owners, read, write, share, read_own
$rec = ACL::explicitAll($f); // legacy + granular
$isOwner = $has($rec['owners'], $user);
$canUpload = $isOwner || $has($rec['write'], $user);
// IMPORTANT: full view only if owner or explicit read
$isOwner = $has($rec['owners'], $user);
$canViewAll = $isOwner || $has($rec['read'], $user);
// own-only view reflects explicit read_own (we keep it separate even if they have full view)
$canViewOwn = $has($rec['read_own'], $user);
$canShare = $isOwner || $has($rec['share'], $user);
$canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user);
// Share only if owner or explicit share
$canShare = $isOwner || $has($rec['share'], $user);
if ($canViewAll || $canViewOwn || $canUpload || $isOwner || $canShare) {
if ($canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner
|| $has($rec['create'],$user) || $has($rec['edit'],$user) || $has($rec['rename'],$user)
|| $has($rec['copy'],$user) || $has($rec['move'],$user) || $has($rec['delete'],$user)
|| $has($rec['extract'],$user) || $has($rec['share_file'],$user) || $has($rec['share_folder'],$user)) {
$out[$f] = [
'view' => $canViewAll,
'viewOwn' => $canViewOwn,
'upload' => $canUpload,
'manage' => $isOwner,
'share' => $canShare,
'view' => $canViewAll,
'viewOwn' => $canViewOwn,
'write' => $has($rec['write'], $user) || $isOwner,
'manage' => $isOwner,
'share' => $canShare, // legacy
'create' => $isOwner || $has($rec['create'], $user),
'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'],$user),
'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'],$user),
'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'],$user),
'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'],$user),
'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'],$user),
'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'],$user),
'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'],$user),
'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'],$user),
'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'],$user),
];
}
}
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);

View File

@@ -1,27 +1,5 @@
<?php
// public/api/admin/acl/saveGrants.php
/**
* @OA\Post(
* path="/api/admin/acl/saveGrants.php",
* summary="Save ACL grants (single-user or batch)",
* tags={"Admin","ACL"},
* security={{"cookieAuth":{}}},
* @OA\RequestBody(
* required=true,
* description="Either {user,grants} or {changes:[{user,grants}]}",
* @OA\JsonContent(oneOf={
* @OA\Schema(ref="#/components/schemas/SaveGrantsSingle"),
* @OA\Schema(ref="#/components/schemas/SaveGrantsBatch")
* })
* ),
* @OA\Response(response=200, description="Saved"),
* @OA\Response(response=400, description="Invalid payload"),
* @OA\Response(response=401, description="Unauthorized"),
* @OA\Response(response=403, description="Invalid CSRF")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../config/config.php';
@@ -47,22 +25,38 @@ if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
}
// ---- Helpers ---------------------------------------------------------------
/**
* Sanitize a grants map to allowed flags only:
* view | viewOwn | upload | manage | share
*/
function normalize_caps(array $row): array {
// booleanize known keys
$bool = function($v){ return !empty($v) && $v !== 'false' && $v !== 0; };
$k = [
'view','viewOwn','upload','manage','share',
'create','edit','rename','copy','move','delete','extract',
'shareFile','shareFolder','write'
];
$out = [];
foreach ($k as $kk) $out[$kk] = $bool($row[$kk] ?? false);
// BUSINESS RULES:
// A) Share Folder REQUIRES View (all). If shareFolder is true but view is false, force view=true.
if ($out['shareFolder'] && !$out['view']) {
$out['view'] = true;
}
// B) Share File requires at least View (own). If neither view nor viewOwn set, set viewOwn=true.
if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) {
$out['viewOwn'] = true;
}
// C) "write" does NOT imply view. It also does not imply granular here; ACL expands legacy write if present.
return $out;
}
function sanitize_grants_map(array $grants): array {
$allowed = ['view','viewOwn','upload','manage','share'];
$out = [];
foreach ($grants as $folder => $caps) {
if (!is_string($folder)) $folder = (string)$folder;
if (!is_array($caps)) $caps = [];
$row = [];
foreach ($allowed as $k) {
$row[$k] = !empty($caps[$k]);
}
// include folder even if all false (signals "remove all for this user on this folder")
$out[$folder] = $row;
$out[$folder] = normalize_caps($caps);
}
return $out;
}
@@ -124,4 +118,4 @@ if (isset($in['changes']) && is_array($in['changes'])) {
// ---- Fallback --------------------------------------------------------------
http_response_code(400);
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);

View File

@@ -51,7 +51,7 @@
* )
*/
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
require_once __DIR__ . '/../../../config/config.php';
@@ -88,30 +88,50 @@ function loadPermsFor(string $u): array {
return [];
}
function isAdminUser(string $u, array $perms): bool {
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
if (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) return true;
$role = $_SESSION['role'] ?? null;
if ($role === 'admin' || $role === '1' || $role === 1) return true;
if ($u) {
$r = userModel::getUserRole($u);
if ($r === '1') return true;
function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
$f = ACL::normalizeFolder($folder);
// direct owner
if (ACL::isOwner($user, $perms, $f)) return true;
// ancestor owner
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
$pos = strrpos($f, '/');
if ($pos === false) break;
$f = substr($f, 0, $pos);
if ($f === '' || strcasecmp($f, 'root') === 0) break;
if (ACL::isOwner($user, $perms, $f)) return true;
}
return false;
}
/**
* folder-only scope:
* - Admins: always in scope
* - Non folder-only accounts: always in scope
* - Folder-only accounts: in scope iff:
* - folder == username OR subpath of username, OR
* - user is owner of this folder (or any ancestor)
*/
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
if ($isAdmin) return true;
$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
if (!$folderOnly) return true;
$f = trim($folder);
if ($f === '' || strcasecmp($f, 'root') === 0) return false; // non-admin folderOnly: not root
return ($f === $u) || (strpos($f, $u . '/') === 0);
//$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
//if (!$folderOnly) return true;
$f = ACL::normalizeFolder($folder);
if ($f === 'root' || $f === '') {
// folder-only users cannot act on root unless they own a subfolder (handled below)
return isOwnerOrAncestorOwner($u, $perms, $f);
}
if ($f === $u || str_starts_with($f, $u . '/')) return true;
// Treat ownership as in-scope
return isOwnerOrAncestorOwner($u, $perms, $f);
}
// --- inputs ---
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
// validate folder path: allow "root" or nested segments matching REGEX_FOLDER_NAME
// validate folder path
if ($folder !== 'root') {
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
if (empty($parts)) {
@@ -129,44 +149,97 @@ if ($folder !== 'root') {
$folder = implode('/', $parts);
}
$perms = loadPermsFor($username);
$isAdmin = isAdminUser($username, $perms);
// --- user + flags ---
$perms = loadPermsFor($username);
$isAdmin = ACL::isAdmin($perms);
$readOnly = !empty($perms['readOnly']);
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
// base permissions via ACL
$canRead = $isAdmin || ACL::canRead($username, $perms, $folder);
$canWrite = $isAdmin || ACL::canWrite($username, $perms, $folder);
$canShare = $isAdmin || ACL::canShare($username, $perms, $folder);
// --- ACL base abilities ---
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder);
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
// scope + flags
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
$readOnly = !empty($perms['readOnly']);
$disableUpload = !empty($perms['disableUpload']);
$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder);
$canUpload = $canWrite && !$readOnly && !$disableUpload && $inScope;
$canCreateFolder = $canWrite && !$readOnly && $inScope;
$canRename = $canWrite && !$readOnly && $inScope;
$canDelete = $canWrite && !$readOnly && $inScope;
$canMoveIn = $canWrite && !$readOnly && $inScope;
// granular base
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
// (optional) owner info if you need it client-side
$owner = FolderModel::getOwnerFor($folder);
// --- Apply scope + flags to effective UI actions ---
$canView = $canViewBase && $inScope; // keep scope for folder-only
$canUpload = $gUploadBase && !$readOnly && $inScope;
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
$canDelete = $gDeleteBase && !$readOnly && $inScope;
// Destination can receive items if user can create/write (or manage) here
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
$canMoveIn = $canReceive;
$canMoveAlias = $canMoveIn;
$canEdit = $gEditBase && !$readOnly && $inScope;
$canCopy = $gCopyBase && !$readOnly && $inScope;
$canExtract = $gExtractBase && !$readOnly && $inScope;
// Sharing respects scope; optionally also gate on readOnly
$canShare = $canShareBase && $inScope; // legacy umbrella
$canShareFileEff = $gShareFile && $inScope;
$canShareFoldEff = $gShareFolder && $inScope;
// never allow destructive ops on root
$isRoot = ($folder === 'root');
if ($isRoot) {
$canRename = false;
$canDelete = false;
$canShareFoldEff = false;
$canMoveFolder = false;
}
if (!$isRoot) {
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
&& !$readOnly;
}
$owner = null;
try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {}
// output
echo json_encode([
'user' => $username,
'folder' => $folder,
'isAdmin' => $isAdmin,
'flags' => [
'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
'user' => $username,
'folder' => $folder,
'isAdmin' => $isAdmin,
'flags' => [
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
'readOnly' => $readOnly,
'disableUpload' => $disableUpload,
],
'owner' => $owner,
'canView' => $canRead,
'canUpload' => $canUpload,
'canCreate' => $canCreateFolder,
'canRename' => $canRename,
'canDelete' => $canDelete,
'canMoveIn' => $canMoveIn,
'canShare' => $canShare,
'owner' => $owner,
// viewing
'canView' => $canView,
'canViewOwn' => $canViewOwn,
// write-ish
'canUpload' => $canUpload,
'canCreate' => $canCreate,
'canRename' => $canRename,
'canDelete' => $canDelete,
'canMoveIn' => $canMoveIn,
'canMove' => $canMoveAlias,
'canMoveFolder'=> $canMoveFolder,
'canEdit' => $canEdit,
'canCopy' => $canCopy,
'canExtract' => $canExtract,
// sharing
'canShare' => $canShare, // legacy
'canShareFile' => $canShareFileEff,
'canShareFolder' => $canShareFoldEff,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

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

View File

@@ -0,0 +1,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();

File diff suppressed because it is too large Load Diff

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

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

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

@@ -0,0 +1,44 @@
/* Roboto Regular 400 — latin-ext */
@font-face{
font-family:'Roboto';
font-style:normal;
font-weight:400;
font-display:swap;
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}') format('woff2');
unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* Roboto Regular 400 — latin */
@font-face{
font-family:'Roboto';
font-style:normal;
font-weight:400;
font-display:swap;
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}') format('woff2');
unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* Roboto Medium 500 — latin-ext */
@font-face{
font-family:'Roboto';
font-style:normal;
font-weight:500;
font-display:swap;
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}') format('woff2');
unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* Roboto Medium 500 — latin */
@font-face{
font-family:'Roboto';
font-style:normal;
font-weight:500;
font-display:swap;
src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}') format('woff2');
unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* sensible stack so Chinese falls back cleanly */
:root{
--ui-font: Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
}
body{ font-family: var(--ui-font); }

View File

@@ -2,142 +2,59 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FileRise</title>
<link rel="icon" type="image/png" href="/assets/logo.png">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<meta name="csrf-token" content="">
<meta name="share-url" content="">
<style>
/* hide the app shell until JS says otherwise */
.main-wrapper {
display: none;
}
/* full-screen white overlay while we check auth */
#loadingOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-color, #fff);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
<style id="pretheme-css">
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
</style>
<!-- Google Fonts and Material Icons -->
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css"
integrity="sha384-zaeBlB/vwYsDRSlFajnDd7OydJ0cWk+c2OWybl3eSUf6hW2EbhlCsQPqKr3gkznT" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css"
integrity="sha384-eZTPTN0EvJdn23s24UDYJmUM2T7C2ZFa3qFLypeBruJv8mZeTusKUAO/j5zPAQ6l" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"
integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/xml/xml.min.js"
integrity="sha384-xPpkMo5nDgD98fIcuRVYhxkZV6/9Y4L8s3p0J5c4MxgJkyKJ8BJr+xfRkq7kn6Tw"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/css/css.min.js"
integrity="sha384-to8njsu2GAiXQnY/aLGzz0DIY/SFSeSDodtvSl869n2NmsBdHOTZNNqbEBPYh7Pa"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/javascript/javascript.min.js"
integrity="sha384-kmQrbJf09Uo1WRLMDVGoVG3nM6F48frIhcj7f3FDUjeRzsiHwyBWDjMUIttnIeAf"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/resumable.js/1.1.0/resumable.min.js"
integrity="sha384-EXTg7rRfdTPZWoKVCslusAAev2TYw76fm+Wox718iEtFQ+gdAdAc5Z/ndLHSo4mq"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.0/purify.min.js"
integrity="sha384-Tsl3d5pUAO7a13enIvSsL3O0/95nsthPJiPto5NtLuY8w3+LbZOpr3Fl2MNmrh1E"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js"
integrity="sha384-zPE55eyESN+FxCWGEnlNxGyAPJud6IZ6TtJmXb56OFRGhxZPN4akj9rjA3gw5Qqa"
crossorigin="anonymous"></script>
<link rel="stylesheet" href="css/styles.css" />
</head>
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="theme-color" content="#0b5ed7"><meta name="color-scheme" content="light dark">
<!-- Critical CSS -->
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
<!-- Fonts (ok to keep as real preloads) -->
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
<!-- Vendor & version (deferred) -->
<script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}" defer></script>
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
<!-- App entry -->
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
</head>
<body>
<div id="appRoot" style="visibility:hidden">
<header class="header-container">
<div class="header-left">
<a href="index.html">
<div class="header-logo">
<svg version="1.1" id="filingCabinetLogo" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64" xml:space="preserve">
<defs>
<!-- Gradient for the cabinet body -->
<linearGradient id="cabinetGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</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>
<img
src="/assets/logo.svg?v={{APP_QVER}}"
alt="FileRise"
class="logo"
width="50"
height="50"
decoding="async"
fetchpriority="high"
/>
</div>
</a>
</div>
<div class="header-title">
<h1 data-i18n-key="header_title">FileRise</h1>
<h1>FileRise</h1>
</div>
<div class="header-right">
<div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;">
<!-- Your header drop zone -->
<div id="headerDropArea" class="header-drop-zone"></div>
<div class="header-buttons">
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
@@ -156,7 +73,7 @@
<!-- Trash items will be loaded here -->
</div>
<div style="text-align: right;">
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected">Restore
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected" style="display: none;">Restore
Selected</button>
<button id="restoreAllBtn" class="btn btn-secondary" data-i18n-key="restore_all">Restore All</button>
<button id="deleteTrashSelectedBtn" class="btn btn-warning" data-i18n-key="delete_selected_trash">Delete
@@ -172,7 +89,7 @@
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
<i class="material-icons">person_remove</i>
</button>
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode">
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode" hidden>
<span class="material-icons" id="darkModeIcon">
dark_mode
</span>
@@ -181,15 +98,18 @@
</div>
</div>
</header>
<div id="loadingOverlay"></div>
<!-- Custom Toast Container -->
<div id="customToast"></div>
<div id="hiddenCardsContainer" style="display:none;"></div>
<main id="main" hidden>
<div class="row mt-4" id="loginForm">
<div class="col-12">
<div id="loginBox" class="login-box">
<div id="fr-login-tip" class="alert alert-info login-hint" role="status" aria-live="polite" style="display:none;"></div>
<form id="authForm" method="post">
<div class="form-group">
<label for="loginUsername" data-i18n-key="user">User:</label>
@@ -199,7 +119,7 @@
<label for="loginPassword" data-i18n-key="password">Password:</label>
<input type="password" class="form-control" id="loginPassword" name="password" required />
</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">
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
<label for="rememberMeCheckbox" data-i18n-key="remember_me">Remember me</label>
@@ -215,11 +135,14 @@
HTTP
Login</a>
</div>
<div>
</div>
</div>
</main>
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
<div class="main-wrapper">
<div class="main-wrapper" hidden>
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
<div id="sidebarDropArea" class="drop-target-sidebar"></div>
<!-- Main Column -->
@@ -286,9 +209,27 @@
</div>
</div>
</div>
<button id="moveFolderBtn" class="btn btn-warning ml-2" data-i18n-title="move_folder">
<i class="material-icons">drive_file_move</i>
</button>
<!-- MOVE FOLDER MODAL (place near your other folder modals) -->
<div id="moveFolderModal" class="modal" style="display:none;">
<div class="modal-content">
<h4 data-i18n-key="move_folder_title">Move Folder</h4>
<p data-i18n-key="move_folder_message">Select a destination folder to move the current folder
into:</p>
<select id="moveFolderTarget" class="form-control modal-input"></select>
<div class="modal-footer" style="margin-top:15px; text-align:right;">
<button id="cancelMoveFolder" class="btn btn-secondary"
data-i18n-key="cancel">Cancel</button>
<button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move" data-default>Move</button>
</div>
</div>
</div>
<button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder">
<i class="material-icons">drive_file_rename_outline</i>
</button>
<div id="renameFolderModal" class="modal">
<div class="modal-content">
<h4 data-i18n-key="rename_folder_title">Rename Folder</h4>
@@ -299,7 +240,7 @@
<button id="cancelRenameFolder" class="btn btn-secondary"
data-i18n-key="cancel">Cancel</button>
<button id="submitRenameFolder" class="btn btn-primary"
data-i18n-key="rename">Rename</button>
data-i18n-key="rename" data-default>Rename</button>
</div>
</div>
</div>
@@ -319,7 +260,7 @@
<button id="cancelDeleteFolder" class="btn btn-secondary"
data-i18n-key="cancel">Cancel</button>
<button id="confirmDeleteFolder" class="btn btn-danger"
data-i18n-key="delete">Delete</button>
data-i18n-key="delete" data-default>Delete</button>
</div>
</div>
</div>
@@ -355,7 +296,7 @@
selected files?</p>
<div class="modal-footer">
<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>
@@ -369,7 +310,7 @@
<select id="copyTargetFolder" class="form-control modal-input"></select>
<div class="modal-footer">
<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>
@@ -383,41 +324,25 @@
<select id="moveTargetFolder" class="form-control modal-input"></select>
<div class="modal-footer">
<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>
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
data-i18n-key="download_zip">Download ZIP</button>
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" data-i18n-title="extract_zip"
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" style="display: none;" disabled
data-i18n-key="extract_zip_button">Extract Zip</button>
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
<button id="createBtn" class="btn action-btn" data-i18n-key="create">
${t('create')} <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
<button id="createBtn" class="btn action-btn" type="button" style="display:none;" aria-haspopup="true" aria-expanded="false">
<span data-i18n-key="create">Create</span>
<span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
</button>
<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: 1000;
min-width: 140px;
"
>
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file" style="padding:8px 12px; cursor:pointer;">
${t('create_file')}
<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;">
<li id="createFileOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
<span data-i18n-key="create_file">Create file</span>
</li>
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder" style="padding:8px 12px; cursor:pointer;">
${t('create_folder')}
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
<span data-i18n-key="create_folder">Create folder</span>
</li>
</ul>
</div>
@@ -425,16 +350,11 @@
<div id="createFileModal" class="modal" style="display:none;">
<div class="modal-content">
<h4 data-i18n-key="create_new_file">Create New File</h4>
<input
type="text"
id="createFileNameInput"
class="form-control"
placeholder="Enter filename…"
data-i18n-placeholder="newfile_placeholder"
/>
<input type="text" id="createFileNameInput" class="form-control" placeholder="Enter filename…"
data-i18n-placeholder="newfile_placeholder" />
<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="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>
@@ -446,7 +366,7 @@
placeholder="files.zip" />
<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="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>
@@ -484,14 +404,14 @@
placeholder="Filename" />
<div style="margin-top: 15px; text-align: right;">
<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>
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
<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>
<h3 data-i18n-key="change_password_title">Change Password</h3>
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
@@ -500,7 +420,7 @@
placeholder="New Password" style="width:100%; margin: 5px 0;" />
<input type="password" id="confirmPassword" class="form-control" data-i18n-placeholder="confirm_new_password"
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 id="addUserModal" class="modal" style="display:none;">
@@ -525,7 +445,7 @@
Cancel
</button>
<!-- 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
</button>
</div>
@@ -550,7 +470,7 @@
placeholder="Enter new file name" style="margin-top:10px;" />
<div style="margin-top:15px; text-align:right;">
<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>
@@ -558,12 +478,12 @@
<div class="modal-content">
<p id="confirmMessage"></p>
<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>
</div>
</div>
</div>
<script type="module" src="js/main.js"></script>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

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 { t, applyTranslations } from './i18n.js';
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
import { t, applyTranslations } from './i18n.js?v={{APP_QVER}}';
import {
toggleVisibility,
showToast as originalShowToast,
attachEnterKeyListener,
showCustomConfirmModal
} from './domUtils.js';
import { loadFileList } from './fileListView.js';
import { initFileActions } from './fileActions.js';
import { renderFileTable } from './fileListView.js';
import { loadFolderTree } from './folderManager.js';
} from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
import { renderFileTable } from './fileListView.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import {
openTOTPLoginModal as originalOpenTOTPLoginModal,
openUserPanel,
@@ -17,9 +17,9 @@ import {
closeTOTPModal,
setLastLoginData,
openApiModal
} from './authModals.js';
import { openAdminPanel } from './adminPanel.js';
import { initializeApp, triggerLogout } from './main.js';
} from './authModals.js?v={{APP_QVER}}';
import { openAdminPanel } from './adminPanel.js?v={{APP_QVER}}';
import { initializeApp, triggerLogout } from './appCore.js?v={{APP_QVER}}';
// Production OIDC configuration (override via API as needed)
const currentOIDCConfig = {
@@ -31,6 +31,49 @@ const 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 ----------------- */
// detect if were in a pendingTOTP state
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
@@ -72,45 +115,51 @@ const originalFetch = window.fetch;
* @param {object} options
* @returns {Promise<Response>}
*/
export async function fetchWithCsrf(url, options = {}) {
// 1) Merge in credentials + header
options = {
credentials: 'include',
...options,
};
const original = window.fetch.bind(window);
const wantJson = (options.headers && /json/i.test(options.headers['Content-Type'] || '')) || typeof options.body === 'string' && options.body.trim().startsWith('{');
options = { credentials: 'include', ...options };
options.headers = {
...(options.headers || {}),
'X-CSRF-Token': window.csrfToken,
'Accept': 'application/json',
...(options.headers || {})
};
// 2) First attempt
let res = await originalFetch(url, options);
// 3) If we got a 403, try to refresh token & retry
if (res.status === 403) {
// 3a) See if the server gave us a new token header
let newToken = res.headers.get('X-CSRF-Token');
// 3b) Otherwise fall back to the /api/auth/token endpoint
if (!newToken) {
const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' });
if (tokRes.ok) {
const body = await tokRes.json();
newToken = body.csrf_token;
}
}
if (newToken) {
// 3c) Update global + meta
window.csrfToken = newToken;
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) meta.content = newToken;
// 3d) Retry the original request with the new token
options.headers['X-CSRF-Token'] = newToken;
res = await originalFetch(url, options);
}
if (window.csrfToken) {
options.headers['X-CSRF-Token'] = window.csrfToken;
}
// 4) Return the real Response—no body peeking here!
async function retryWithFreshCsrf(asFormFallback = false) {
const tokRes = await original('/api/auth/token.php', { credentials: 'include' });
if (tokRes.ok) {
const body = await tokRes.json().catch(() => ({}));
if (body?.csrf_token) {
window.csrfToken = body.csrf_token;
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) meta.content = body.csrf_token;
options.headers['X-CSRF-Token'] = body.csrf_token;
}
}
if (asFormFallback && wantJson) {
// convert JSON body into x-www-form-urlencoded
const orig = options.body && typeof options.body === 'string' ? JSON.parse(options.body) : {};
options.body = toFormBody(orig);
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
return original(url, options);
}
let res = await original(url, options);
// If API doesnt like JSON or token is stale
if (res.status === 400 || res.status === 403 || res.status === 415) {
// 1) retry with fresh CSRF keeping same encoding
res = await retryWithFreshCsrf(false);
if (!res.ok && wantJson) {
// 2) retry again as form-encoded
res = await retryWithFreshCsrf(true);
}
}
return res;
}
@@ -180,7 +229,7 @@ function updateLoginOptionsUIFromStorage() {
}
export function loadAdminConfigFunc() {
return fetch("/api/admin/getConfig.php", { credentials: "include" })
return fetch("/api/siteConfig.php", { credentials: "include" })
.then(async (response) => {
// If a proxy or some edge returns 204/empty, handle gracefully
let config = {};
@@ -191,13 +240,13 @@ export function loadAdminConfigFunc() {
document.title = headerTitle;
const lo = config.loginOptions || {};
localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin));
localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth));
localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin));
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin));
localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth));
localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin));
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
// These may be absent for non-admins; default them
localStorage.setItem("authBypass", String(!!lo.authBypass));
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
localStorage.setItem("authBypass", String(!!lo.authBypass));
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
updateLoginOptionsUIFromStorage();
@@ -253,14 +302,14 @@ export async function updateAuthenticatedUI(data) {
if (loading) loading.remove();
// 2) Show main UI
document.querySelector('.main-wrapper').style.display = '';
document.getElementById('loginForm').style.display = 'none';
document.querySelector('.main-wrapper').style.display = '';
document.getElementById('loginForm').style.display = 'none';
toggleVisibility("loginForm", false);
toggleVisibility("mainOperations", true);
toggleVisibility("uploadFileForm", true);
toggleVisibility("fileListContainer", true);
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn");
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
document.querySelector(".header-buttons").style.visibility = "visible";
// 3) Persist auth flags (unchanged)
@@ -271,9 +320,9 @@ export async function updateAuthenticatedUI(data) {
localStorage.setItem("username", data.username);
}
if (typeof data.folderOnly !== "undefined") {
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
localStorage.setItem("disableUpload",data.disableUpload? "true" : "false");
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
}
// 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage
@@ -282,7 +331,7 @@ export async function updateAuthenticatedUI(data) {
// 5) Build / update header buttons
const headerButtons = document.querySelector(".header-buttons");
const firstButton = headerButtons.firstElementChild;
const firstButton = headerButtons.firstElementChild;
// a) restore-from-trash for admins
if (data.isAdmin) {
@@ -290,8 +339,8 @@ export async function updateAuthenticatedUI(data) {
if (!r) {
r = document.createElement("button");
r.id = "restoreFilesBtn";
r.classList.add("btn","btn-warning");
r.setAttribute("data-i18n-title","trash_restore_delete");
r.classList.add("btn", "btn-warning");
r.setAttribute("data-i18n-title", "trash_restore_delete");
r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
if (firstButton) insertAfter(r, firstButton);
else headerButtons.appendChild(r);
@@ -308,8 +357,8 @@ export async function updateAuthenticatedUI(data) {
if (!a) {
a = document.createElement("button");
a.id = "adminPanelBtn";
a.classList.add("btn","btn-info");
a.setAttribute("data-i18n-title","admin_panel");
a.classList.add("btn", "btn-info");
a.setAttribute("data-i18n-title", "admin_panel");
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
insertAfter(a, document.getElementById("restoreFilesBtn"));
a.addEventListener("click", openAdminPanel);
@@ -330,19 +379,19 @@ export async function updateAuthenticatedUI(data) {
: `<i class="material-icons">account_circle</i>`;
// fallback username if missing
const usernameText = data.username
|| localStorage.getItem("username")
const usernameText = data.username
|| localStorage.getItem("username")
|| "";
if (!dd) {
dd = document.createElement("div");
dd.id = "userDropdown";
dd.id = "userDropdown";
dd.classList.add("user-dropdown");
// toggle button
const toggle = document.createElement("button");
toggle.id = "userDropdownToggle";
toggle.classList.add("btn","btn-user");
toggle.id = "userDropdownToggle";
toggle.classList.add("btn", "btn-user");
toggle.setAttribute("title", t("user_settings"));
toggle.innerHTML = `
${avatarHTML}
@@ -464,6 +513,14 @@ function checkAuthentication(showLoginToast = true) {
}
updateAuthenticatedUI(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 {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.remove();
@@ -484,53 +541,162 @@ function checkAuthentication(showLoginToast = true) {
}
/* ----------------- Authentication Submission ----------------- */
async function primeCsrfStrict() {
const r = await fetch('/api/auth/token.php', { credentials: 'include' });
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.csrf_token) throw new Error('CSRF missing');
window.csrfToken = j.csrf_token;
const m = document.querySelector('meta[name="csrf-token"]');
if (m) m.content = j.csrf_token;
}
function toFormBody(obj) {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(obj || {})) p.set(k, v == null ? '' : String(v));
return p.toString();
}
async function safeJson(res) {
const ct = res.headers.get('content-type') || '';
if (!/application\/json/i.test(ct)) return null;
try { return await res.clone().json(); } catch { return null; }
}
async function sniffTOTP(res, bodyMaybe) {
if (res.headers.get('X-TOTP-Required') === '1') return true;
if (res.redirected && /[?&]totp_required=1\b/.test(res.url)) return true;
const body = bodyMaybe ?? await safeJson(res);
if (body && (body.totp_required || body.error === 'TOTP_REQUIRED')) return true;
try {
const txt = await res.clone().text();
if (/\btotp_required\s*=\s*1\b/i.test(txt)) return true;
} catch { }
return false;
}
async function isAuthedNow() {
try {
const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
const j = await r.json().catch(() => ({}));
return !!j.authenticated;
} catch { return false; }
}
function rafTick(times = 2) {
return new Promise(res => {
const step = () => { if (--times <= 0) res(); else requestAnimationFrame(step); };
requestAnimationFrame(step);
});
}
async function fetchAuthSnapshot() {
try {
const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
return await r.json();
} catch { return {}; }
}
async function syncPermissionsToLocalStorage() {
try {
const r = await fetch('/api/getUserPermissions.php', { credentials: 'include' });
const perm = await r.json();
if (perm && typeof perm === 'object') {
localStorage.setItem('folderOnly', perm.folderOnly ? 'true' : 'false');
localStorage.setItem('readOnly', perm.readOnly ? 'true' : 'false');
localStorage.setItem('disableUpload', perm.disableUpload ? 'true' : 'false');
}
} catch { /* non-fatal */ }
}
// ——— main ———
let __loginInFlight = false;
async function submitLogin(data) {
setLastLoginData(data);
window.__lastLoginData = 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 {
// ─── 1) Get CSRF for the initial auth call ───
let res = await fetch("/api/auth/token.php", { credentials: "include" });
if (!res.ok) throw new Error("Could not fetch CSRF token");
window.csrfToken = (await res.json()).csrf_token;
await primeCsrfStrict();
// ─── 2) Send credentials ───
const response = await sendRequest(
"/api/auth/auth.php",
"POST",
data,
{ "X-CSRF-Token": window.csrfToken }
);
// 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);
// ─── 3a) Full login (no TOTP) ───
if (response.success || response.status === "ok") {
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!");
// … fetch permissions & reload …
// TOTP requested?
if (await sniffTOTP(res, body)) {
try { await primeCsrfStrict(); } catch { }
window.pendingTOTP = true;
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");
}
const auth = await import('/js/auth.js?v={{APP_QVER}}');
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
} catch { }
return window.location.reload();
return;
}
// ─── 3b) TOTP required ───
if (response.totp_required) {
// **Refresh** CSRF before the TOTP verify call
res = await fetch("/api/auth/token.php", { credentials: "include" });
if (res.ok) {
window.csrfToken = (await res.json()).csrf_token;
}
// now open the modal—any totp_verify fetch from here on will use the new token
return openTOTPLoginModal();
// Full success (no TOTP)
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
await syncPermissionsToLocalStorage();
return afterLogin();
}
// ─── 3c) Too many attempts ───
if (response.error && response.error.includes("Too many failed login attempts")) {
showToast(response.error);
// 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']");
if (btn) {
btn.disabled = true;
@@ -542,12 +708,12 @@ async function submitLogin(data) {
return;
}
// ─── 3d) Other failures ───
showToast("Login failed: " + (response.error || "Unknown error"));
showToast('Login failed' + (body?.error ? `: ${body.error}` : ''));
} catch (err) {
const msg = err.message || err.error || "Unknown error";
showToast(`Login failed: ${msg}`);
} catch (e) {
showToast('Login failed: ' + (e.message || 'Unknown error'));
} 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 { sendRequest } from './networkUtils.js';
import { t, applyTranslations, setLocale } from './i18n.js';
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
import { t, applyTranslations, setLocale } from './i18n.js?v={{APP_QVER}}';
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js?v={{APP_QVER}}';
let lastLoginData = null;
export function setLastLoginData(data) {
@@ -328,10 +328,19 @@ export async function openUserPanel() {
const langSel = document.createElement('select');
langSel.id = 'languageSelector';
langSel.className = 'form-select';
['en', 'es', 'fr', 'de'].forEach(code => {
const languages = [
{ code: 'en', labelKey: 'english', fallback: 'English' },
{ code: 'es', labelKey: 'spanish', fallback: 'Español' },
{ code: 'fr', labelKey: 'french', fallback: 'Français' },
{ code: 'de', labelKey: 'german', fallback: 'Deutsch' },
{ code: 'zh-CN', labelKey: 'chinese_simplified', fallback: '简体中文' },
];
languages.forEach(({ code, labelKey, fallback }) => {
const opt = document.createElement('option');
opt.value = code;
opt.textContent = t(code === 'en' ? 'english' : code === 'es' ? 'spanish' : code === 'fr' ? 'french' : 'german');
// use i18n if available, otherwise fallback
opt.textContent = (typeof t === 'function' ? t(labelKey) : '') || fallback;
langSel.appendChild(opt);
});
langSel.value = localStorage.getItem('language') || 'en';

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

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

View File

@@ -1,6 +1,6 @@
// domUtils.js
import { t } from './i18n.js';
import { openDownloadModal } from './fileActions.js';
import { t } from './i18n.js?v={{APP_QVER}}';
import { openDownloadModal } from './fileActions.js?v={{APP_QVER}}';
// Basic DOM Helpers
export function toggleVisibility(elementId, shouldShow) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
// fileActions.js
import { showToast, attachEnterKeyListener } from './domUtils.js';
import { loadFileList } from './fileListView.js';
import { formatFolderName } from './fileListView.js';
import { t } from './i18n.js';
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
export function handleDeleteSelected(e) {
e.preventDefault();
@@ -31,6 +31,7 @@ document.addEventListener("DOMContentLoaded", function () {
const confirmDelete = document.getElementById("confirmDeleteFiles");
if (confirmDelete) {
confirmDelete.setAttribute("data-default", "");
confirmDelete.addEventListener("click", function () {
fetch("/api/file/deleteFiles.php", {
method: "POST",
@@ -316,6 +317,7 @@ document.addEventListener("DOMContentLoaded", () => {
// 2) Confirm button kicks off the zip+download
if (confirmZipBtn) {
confirmZipBtn.setAttribute("data-default", "");
confirmZipBtn.addEventListener("click", async () => {
// a) Validate ZIP filename
let zipName = document.getElementById("zipFileNameInput").value.trim();
@@ -478,6 +480,7 @@ document.addEventListener("DOMContentLoaded", function () {
}
const confirmCopy = document.getElementById("confirmCopyFiles");
if (confirmCopy) {
confirmCopy.setAttribute("data-default", "");
confirmCopy.addEventListener("click", function () {
const targetFolder = document.getElementById("copyTargetFolder").value;
if (!targetFolder) {
@@ -529,6 +532,7 @@ document.addEventListener("DOMContentLoaded", function () {
}
const confirmMove = document.getElementById("confirmMoveFiles");
if (confirmMove) {
confirmMove.setAttribute("data-default", "");
confirmMove.addEventListener("click", function () {
const targetFolder = document.getElementById("moveTargetFolder").value;
if (!targetFolder) {
@@ -598,6 +602,7 @@ document.addEventListener("DOMContentLoaded", () => {
const submitBtn = document.getElementById("submitRenameFile");
if (submitBtn) {
submitBtn.setAttribute("data-default", "");
submitBtn.addEventListener("click", function () {
const newName = document.getElementById("newFileName").value.trim();
if (!newName || newName === window.fileToRename) {

View File

@@ -1,6 +1,6 @@
// fileDragDrop.js
import { showToast } from './domUtils.js';
import { loadFileList } from './fileListView.js';
import { showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
export function fileDragStartHandler(event) {
const row = event.currentTarget;

View File

@@ -1,147 +1,180 @@
// fileEditor.js
import { escapeHTML, showToast } from './domUtils.js';
import { loadFileList } from './fileListView.js';
import { t } from './i18n.js';
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
// thresholds for editor behavior
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
// Lazy-load CodeMirror modes on demand
const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
const MODE_URL = {
// core you've likely already loaded:
"xml": "mode/xml/xml.min.js",
"css": "mode/css/css.min.js",
"javascript": "mode/javascript/javascript.min.js",
// ==== CodeMirror lazy loader ===============================================
const CM_BASE = "/vendor/codemirror/5.65.5/";
// extras you may want on-demand:
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js",
"application/x-httpd-php": "mode/php/php.min.js",
"php": "mode/php/php.min.js",
"markdown": "mode/markdown/markdown.min.js",
"python": "mode/python/python.min.js",
"sql": "mode/sql/sql.min.js",
"shell": "mode/shell/shell.min.js",
"yaml": "mode/yaml/yaml.min.js",
"properties": "mode/properties/properties.min.js",
"text/x-csrc": "mode/clike/clike.min.js",
"text/x-c++src": "mode/clike/clike.min.js",
"text/x-java": "mode/clike/clike.min.js",
"text/x-csharp": "mode/clike/clike.min.js",
"text/x-kotlin": "mode/clike/clike.min.js"
// 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
const MODE_URL = {
// core/common
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
"css": "mode/css/css.min.js?v={{APP_QVER}}",
"javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}",
// meta / combos
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
"application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}",
// docs / data
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
"properties": "mode/properties/properties.min.js?v={{APP_QVER}}",
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
// shells
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
// languages
"python": "mode/python/python.min.js?v={{APP_QVER}}",
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
};
// 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
function normalizeModeName(modeOption) {
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
if (!name) return null;
if (name === "text/html") return "htmlmixed"; // CodeMirror uses htmlmixed for HTML
if (name === "php") return "application/x-httpd-php"; // prefer the full mime
return name;
}
const _loadedScripts = new Set();
const _loadedCss = new Set();
let _corePromise = null;
function loadScriptOnce(url) {
return new Promise((resolve, reject) => {
const key = `cm:${url}`;
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);
return;
}
s = document.createElement("script");
if (_loadedScripts.has(url)) return resolve();
const s = document.createElement("script");
s.src = url;
s.defer = true;
s.dataset.key = key;
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
s.addEventListener("error", reject);
s.async = true;
s.onload = () => { _loadedScripts.add(url); resolve(); };
s.onerror = () => reject(new Error(`Load failed: ${url}`));
document.head.appendChild(s);
});
}
async function ensureModeLoaded(modeOption) {
if (!window.CodeMirror) return; // CM core must be present
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
if (!name) return;
// Already registered?
if ((CodeMirror.modes && CodeMirror.modes[name]) || (CodeMirror.mimeModes && CodeMirror.mimeModes[name])) {
return;
}
const url = MODE_URL[name];
if (!url) return; // unknown -> fallback to text/plain
// Dependencies (htmlmixed needs xml/css/js; php highlighting with HTML also benefits from htmlmixed)
if (name === "htmlmixed") {
await Promise.all([
ensureModeLoaded("xml"),
ensureModeLoaded("css"),
ensureModeLoaded("javascript")
]);
}
if (name === "application/x-httpd-php") {
await ensureModeLoaded("htmlmixed");
}
await loadScriptOnce(CM_CDN + url);
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) {
await ensureCore();
const name = normalizeModeName(modeOption);
if (!name) return;
if (isModeRegistered(name)) return;
const deps = MODE_DEPS[name] || [];
for (const d of deps) {
if (!isModeRegistered(d)) await loadSingleMode(d);
}
await loadSingleMode(name);
}
// 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) {
const dot = fileName.lastIndexOf(".");
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
switch (ext) {
// markup
case "html":
case "htm":
return "text/html"; // ensureModeLoaded will map to htmlmixed
case "xml":
return "xml";
case "htm": return "text/html";
case "xml": return "xml";
case "md":
case "markdown":
return "markdown";
case "markdown": return "markdown";
case "yml":
case "yaml":
return "yaml";
// styles & scripts
case "css":
return "css";
case "js":
return "javascript";
case "json":
return { name: "javascript", json: true };
// server / langs
case "php":
return "application/x-httpd-php";
case "py":
return "python";
case "sql":
return "sql";
case "yaml": return "yaml";
case "css": return "css";
case "js": return "javascript";
case "json": return { name: "javascript", json: true };
case "php": return "application/x-httpd-php";
case "py": return "python";
case "sql": return "sql";
case "sh":
case "bash":
case "zsh":
case "bat":
return "shell";
// config-y files
case "bat": return "shell";
case "ini":
case "conf":
case "config":
case "properties":
return "properties";
// C-family / JVM
case "properties": return "properties";
case "c":
case "h":
return "text/x-csrc";
case "h": return "text/x-csrc";
case "cpp":
case "cxx":
case "hpp":
case "hh":
case "hxx":
return "text/x-c++src";
case "java":
return "text/x-java";
case "cs":
return "text/x-csharp";
case "hxx": return "text/x-c++src";
case "java": return "text/x-java";
case "cs": return "text/x-csharp";
case "kt":
case "kts":
return "text/x-kotlin";
default:
return "text/plain";
case "kts": return "text/x-kotlin";
default: return "text/plain";
}
}
export { getModeForFile };
@@ -158,138 +191,191 @@ export { adjustEditorSize };
function observeModalResize(modal) {
if (!modal) return;
const resizeObserver = new ResizeObserver(() => {
adjustEditorSize();
});
const resizeObserver = new ResizeObserver(() => adjustEditorSize());
resizeObserver.observe(modal);
}
export { observeModalResize };
export function editFile(fileName, folder) {
// destroy any previous editor
let existingEditor = document.getElementById("editorContainer");
if (existingEditor) {
existingEditor.remove();
}
if (existingEditor) existingEditor.remove();
const folderUsed = folder || window.currentFolder || "root";
const folderPath = folderUsed === "root"
? "uploads/"
: "uploads/" + folderUsed.split("/").map(encodeURIComponent).join("/") + "/";
const fileUrl = folderPath + encodeURIComponent(fileName) + "?t=" + new Date().getTime();
const fileUrl = buildPreviewUrl(folderUsed, fileName);
fetch(fileUrl, { method: "HEAD" })
.then(response => {
const lenHeader =
response.headers.get("content-length") ??
response.headers.get("Content-Length");
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
// Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET.
async function probeSize(url) {
try {
const h = await fetch(url, { method: "HEAD", credentials: "include" });
const len = h.headers.get("content-length") ?? h.headers.get("Content-Length");
if (len && !Number.isNaN(parseInt(len, 10))) return parseInt(len, 10);
} catch { }
try {
const r = await fetch(url, {
method: "GET",
headers: { Range: "bytes=0-0" },
credentials: "include"
});
// Content-Range: bytes 0-0/12345
const cr = r.headers.get("content-range") ?? r.headers.get("Content-Range");
const m = cr && cr.match(/\/(\d+)\s*$/);
if (m) return parseInt(m[1], 10);
} catch { }
return null;
}
probeSize(fileUrl)
.then(sizeBytes => {
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
throw new Error("File too large.");
}
return response;
return fetch(fileUrl, { credentials: "include" });
})
.then(() => fetch(fileUrl))
.then(response => {
if (!response.ok) {
throw new Error("HTTP error! Status: " + response.status);
}
const lenHeader =
response.headers.get("content-length") ??
response.headers.get("Content-Length");
if (!response.ok) throw new Error("HTTP error! Status: " + response.status);
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
return Promise.all([response.text(), sizeBytes]);
})
.then(([content, sizeBytes]) => {
const forcePlainText =
sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
const forcePlainText = sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
// --- Build modal immediately and wire close controls BEFORE any async loads ---
const modal = document.createElement("div");
modal.id = "editorContainer";
modal.classList.add("modal", "editor-modal");
modal.setAttribute("tabindex", "-1"); // for Escape handling
modal.innerHTML = `
<div class="editor-header">
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}${
forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""
}</h3>
<div class="editor-controls">
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
<div class="editor-header">
<h3 class="editor-title">
${t("editing")}: ${escapeHTML(fileName)}
${forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""}
</h3>
<div class="editor-controls">
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
</div>
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close")}">&times;</button>
</div>
<button id="closeEditorX" class="editor-close-btn">&times;</button>
</div>
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
<div class="editor-footer">
<button id="saveBtn" class="btn btn-primary">${t("save")}</button>
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
</div>
`;
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
<div class="editor-footer">
<button id="saveBtn" class="btn btn-primary" data-default disabled>${t("save")} </button>
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
</div>
`;
document.body.appendChild(modal);
modal.style.display = "block";
modal.focus();
const isDarkMode = document.body.classList.contains("dark-mode");
const theme = isDarkMode ? "material-darker" : "default";
// choose mode + lighter settings for large files
const mode = forcePlainText ? "text/plain" : getModeForFile(fileName);
const cmOptions = {
lineNumbers: !forcePlainText,
mode: mode,
theme: theme,
viewportMargin: forcePlainText ? 20 : Infinity,
lineWrapping: false,
let canceled = false;
const doClose = () => {
canceled = true;
window.currentEditor = null;
modal.remove();
};
// ✅ LOAD MODE FIRST, THEN INSTANTIATE CODEMIRROR
ensureModeLoaded(mode).finally(() => {
const editor = CodeMirror.fromTextArea(
// Wire close actions right away
modal.addEventListener("keydown", (e) => { if (e.key === "Escape") doClose(); });
document.getElementById("closeEditorX").addEventListener("click", doClose);
document.getElementById("closeBtn").addEventListener("click", doClose);
// Keep buttons responsive even before editor exists
const decBtn = document.getElementById("decreaseFont");
const incBtn = document.getElementById("increaseFont");
decBtn.addEventListener("click", () => { });
incBtn.addEventListener("click", () => { });
// Theme + mode selection
const isDarkMode = document.body.classList.contains("dark-mode");
const theme = isDarkMode ? "material-darker" : "default";
const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName);
// Start core+mode loading (dont block closing)
const modePromise = (async () => {
await ensureCore(); // load CM core + CSS
if (!forcePlainText) {
await ensureModeLoaded(desiredMode); // then load the needed mode + deps
}
})();
// Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available
const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS));
Promise.race([modePromise, timeout]).then(() => {
if (canceled) return;
if (!window.CodeMirror) {
// Core not present: keep plain <textarea>; enable Save and bail gracefully
document.getElementById("saveBtn").disabled = false;
observeModalResize(modal);
return;
}
const normName = normalizeModeName(desiredMode) || "text/plain";
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
const cmOptions = {
lineNumbers: !forcePlainText,
mode: initialMode,
theme,
viewportMargin: forcePlainText ? 20 : Infinity,
lineWrapping: false
};
const editor = window.CodeMirror.fromTextArea(
document.getElementById("fileEditor"),
cmOptions
);
window.currentEditor = editor;
setTimeout(() => {
adjustEditorSize();
}, 50);
setTimeout(adjustEditorSize, 50);
observeModalResize(modal);
// Font controls (now that editor exists)
let currentFontSize = 14;
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
const wrapper = editor.getWrapperElement();
wrapper.style.fontSize = currentFontSize + "px";
editor.refresh();
document.getElementById("closeEditorX").addEventListener("click", function () {
modal.remove();
});
document.getElementById("decreaseFont").addEventListener("click", function () {
decBtn.addEventListener("click", function () {
currentFontSize = Math.max(8, currentFontSize - 2);
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
wrapper.style.fontSize = currentFontSize + "px";
editor.refresh();
});
document.getElementById("increaseFont").addEventListener("click", function () {
incBtn.addEventListener("click", function () {
currentFontSize = Math.min(32, currentFontSize + 2);
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
wrapper.style.fontSize = currentFontSize + "px";
editor.refresh();
});
document.getElementById("saveBtn").addEventListener("click", function () {
// Save
const saveBtn = document.getElementById("saveBtn");
saveBtn.disabled = false;
saveBtn.addEventListener("click", function () {
saveFile(fileName, folderUsed);
});
document.getElementById("closeBtn").addEventListener("click", function () {
modal.remove();
});
// Theme switch
function updateEditorTheme() {
const isDark = document.body.classList.contains("dark-mode");
editor.setOption("theme", isDark ? "material-darker" : "default");
}
const toggle = document.getElementById("darkModeToggle");
if (toggle) toggle.addEventListener("click", updateEditorTheme);
// If we started in plain text due to timeout, flip to the real mode once it arrives
modePromise.then(() => {
if (!canceled && !forcePlainText) {
const nn = normalizeModeName(desiredMode);
if (nn && isModeRegistered(nn)) {
editor.setOption("mode", desiredMode);
}
}
}).catch(() => {
// If the mode truly fails to load, we just stay in plain text
});
});
})
.catch(error => {
@@ -298,7 +384,6 @@ export function editFile(fileName, folder) {
});
}
export function saveFile(fileName, folder) {
const editor = window.currentEditor;
if (!editor) {

View File

@@ -11,11 +11,11 @@ import {
updateRowHighlight,
toggleRowSelection,
attachEnterKeyListener
} from './domUtils.js';
import { t } from './i18n.js';
import { bindFileListContextMenu } from './fileMenu.js';
import { openDownloadModal } from './fileActions.js';
import { openTagModal, openMultiTagModal } from './fileTags.js';
} from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { bindFileListContextMenu } from './fileMenu.js?v={{APP_QVER}}';
import { openDownloadModal } from './fileActions.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
import {
getParentFolder,
updateBreadcrumbTitle,
@@ -24,13 +24,13 @@ import {
hideFolderManagerContextMenu,
openRenameFolderModal,
openDeleteFolderModal
} from './folderManager.js';
import { openFolderShareModal } from './folderShareModal.js';
} from './folderManager.js?v={{APP_QVER}}';
import { openFolderShareModal } from './folderShareModal.js?v={{APP_QVER}}';
import {
folderDragOverHandler,
folderDragLeaveHandler,
folderDropHandler
} from './fileDragDrop.js';
} from './fileDragDrop.js?v={{APP_QVER}}';
export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true };
@@ -205,29 +205,84 @@ function wireSelectAll(fileListContent) {
/**
* Fuse.js fuzzy search helper
*/
function searchFiles(searchTerm) {
if (!searchTerm) return fileData;
let keys = [
{ name: 'name', weight: 0.1 },
{ name: 'uploader', weight: 0.1 },
{ name: 'tags.name', weight: 0.1 }
];
if (window.advancedSearchEnabled) {
keys.push({ name: 'content', weight: 0.7 });
}
// --- 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) {
if (!searchTerm) return fileData;
// 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: 'uploader', weight: 0.1 },
{ name: 'tags.name', weight: 0.1 }
];
if (window.advancedSearchEnabled) {
fuseKeys.push({ name: 'content', weight: 0.7 });
}
// If Fuse is present, use it right away (synchronous API)
if (window.Fuse) {
const options = {
keys: keys,
keys: fuseKeys,
threshold: 0.4,
minMatchCharLength: 2,
ignoreLocation: true
};
const fuse = new Fuse(fileData, options);
let results = fuse.search(searchTerm);
return results.map(result => result.item);
const fuse = new window.Fuse(fileData, options);
const results = fuse.search(searchTerm);
return results.map(r => r.item);
}
// Fallback (first keystrokes before Fuse finishes loading):
// simple case-insensitive substring match on the same fields
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;
});
}
/**
* View mode toggle
@@ -750,7 +805,7 @@ function wireSelectAll(fileListContent) {
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
btn.addEventListener("click", async e => {
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);
});
});
@@ -759,7 +814,7 @@ function wireSelectAll(fileListContent) {
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
btn.addEventListener("click", async e => {
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);
});
});
@@ -768,7 +823,7 @@ function wireSelectAll(fileListContent) {
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
btn.addEventListener("click", async e => {
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);
});
});
@@ -822,7 +877,7 @@ function wireSelectAll(fileListContent) {
const fileName = this.getAttribute("data-file");
const file = fileData.find(f => f.name === fileName);
if (file) {
import('./filePreview.js').then(module => {
import('./filePreview.js?v={{APP_QVER}}').then(module => {
module.openShareModal(file, folder);
});
}
@@ -831,7 +886,7 @@ function wireSelectAll(fileListContent) {
updateFileActionButtons();
document.querySelectorAll("#fileList tbody tr").forEach(row => {
row.setAttribute("draggable", "true");
import('./fileDragDrop.js').then(module => {
import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
row.addEventListener("dragstart", module.fileDragStartHandler);
});
});
@@ -1085,7 +1140,7 @@ function wireSelectAll(fileListContent) {
// preview clicks (dynamic import to avoid global dependency)
fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
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);
});
});
@@ -1102,7 +1157,7 @@ function wireSelectAll(fileListContent) {
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
btn.addEventListener("click", async e => {
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);
});
});
@@ -1111,7 +1166,7 @@ function wireSelectAll(fileListContent) {
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
btn.addEventListener("click", async e => {
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);
});
});
@@ -1123,7 +1178,7 @@ function wireSelectAll(fileListContent) {
const fileName = btn.dataset.file;
const fileObj = fileData.find(f => f.name === fileName);
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
import './fileListView.js';
import './filePreview.js';
import './fileEditor.js';
import './fileDragDrop.js';
import './fileMenu.js';
import { initFileActions } from './fileActions.js';
import './fileListView.js?v={{APP_QVER}}';
import './filePreview.js?v={{APP_QVER}}';
import './fileEditor.js?v={{APP_QVER}}';
import './fileDragDrop.js?v={{APP_QVER}}';
import './fileMenu.js?v={{APP_QVER}}';
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
// Initialize file action buttons.
document.addEventListener("DOMContentLoaded", function () {
@@ -14,7 +14,7 @@ document.addEventListener("DOMContentLoaded", function () {
// Attach folder drag-and-drop support for folder tree nodes.
document.addEventListener("DOMContentLoaded", function () {
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("dragleave", module.folderDragLeaveHandler);
el.addEventListener("drop", module.folderDropHandler);
@@ -32,7 +32,7 @@ document.addEventListener("keydown", function(e) {
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
if (selectedCheckboxes.length > 0) {
e.preventDefault();
import('./fileActions.js').then(module => {
import('./fileActions.js?v={{APP_QVER}}').then(module => {
module.handleDeleteSelected(new Event("click"));
});
}

View File

@@ -1,11 +1,11 @@
// fileMenu.js
import { updateRowHighlight, showToast } from './domUtils.js';
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js';
import { previewFile } from './filePreview.js';
import { editFile } from './fileEditor.js';
import { canEditFile, fileData } from './fileListView.js';
import { openTagModal, openMultiTagModal } from './fileTags.js';
import { t } from './i18n.js';
import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}';
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}';
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
export function showFileContextMenu(x, y, menuItems) {
let menu = document.getElementById("fileContextMenu");
@@ -39,11 +39,11 @@ export function showFileContextMenu(x, y, menuItems) {
});
menu.appendChild(menuItem);
});
menu.style.left = x + "px";
menu.style.top = y + "px";
menu.style.display = "block";
const menuRect = menu.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (menuRect.bottom > viewportHeight) {
@@ -62,7 +62,7 @@ export function hideFileContextMenu() {
export function fileListContextMenuHandler(e) {
e.preventDefault();
let row = e.target.closest("tr");
if (row) {
const checkbox = row.querySelector(".file-checkbox");
@@ -71,9 +71,9 @@ export function fileListContextMenuHandler(e) {
updateRowHighlight(checkbox);
}
}
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
let menuItems = [
{ label: t("create_file"), action: () => openCreateFileModal() },
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
@@ -81,14 +81,14 @@ export function fileListContextMenuHandler(e) {
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
{ label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } }
];
if (selected.some(name => name.toLowerCase().endsWith(".zip"))) {
menuItems.push({
label: t("extract_zip"),
action: () => { handleExtractZipSelected(new Event("click")); }
});
}
if (selected.length > 1) {
menuItems.push({
label: t("tag_selected"),
@@ -100,36 +100,33 @@ export function fileListContextMenuHandler(e) {
}
else if (selected.length === 1) {
const file = fileData.find(f => f.name === selected[0]);
menuItems.push({
label: t("preview"),
action: () => {
const folder = window.currentFolder || "root";
const folderPath = folder === "root"
? "uploads/"
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
previewFile(folderPath + encodeURIComponent(file.name) + "?t=" + new Date().getTime(), file.name);
previewFile(buildPreviewUrl(folder, file.name), file.name);
}
});
if (canEditFile(file.name)) {
menuItems.push({
label: t("edit"),
action: () => { editFile(selected[0], window.currentFolder); }
});
}
menuItems.push({
label: t("rename"),
action: () => { renameFile(selected[0], window.currentFolder); }
});
menuItems.push({
label: t("tag_file"),
action: () => { openTagModal(file); }
});
}
showFileContextMenu(e.clientX, e.clientY, menuItems);
}
@@ -140,7 +137,7 @@ export function bindFileListContextMenu() {
}
}
document.addEventListener("click", function(e) {
document.addEventListener("click", function (e) {
const menu = document.getElementById("fileContextMenu");
if (menu && menu.style.display === "block") {
hideFileContextMenu();
@@ -148,9 +145,9 @@ document.addEventListener("click", function(e) {
});
// Rebind context menu after file table render.
(function() {
(function () {
const originalRenderFileTable = window.renderFileTable;
window.renderFileTable = function(folder) {
window.renderFileTable = function (folder) {
originalRenderFileTable(folder);
bindFileListContextMenu();
};

View File

@@ -1,7 +1,13 @@
// filePreview.js
import { escapeHTML, showToast } from './domUtils.js';
import { fileData } from './fileListView.js';
import { t } from './i18n.js';
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
import { fileData } from './fileListView.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
// Build a preview URL that always goes through the API layer (respects ACLs/UPLOAD_DIR)
export function buildPreviewUrl(folder, name) {
const f = (!folder || folder === '') ? 'root' : String(folder);
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
}
export function openShareModal(file, folder) {
// Remove any existing modal
@@ -92,10 +98,10 @@ export function openShareModal(file, folder) {
if (sel.value === "custom") {
value = parseInt(document.getElementById("customExpirationValue").value, 10);
unit = document.getElementById("customExpirationUnit").value;
unit = document.getElementById("customExpirationUnit").value;
} else {
value = parseInt(sel.value, 10);
unit = "minutes";
unit = "minutes";
}
const password = document.getElementById("sharePassword").value;
@@ -115,20 +121,20 @@ export function openShareModal(file, folder) {
password
})
})
.then(res => res.json())
.then(data => {
if (data.token) {
const url = `${window.location.origin}/api/file/share.php?token=${encodeURIComponent(data.token)}`;
document.getElementById("shareLinkInput").value = url;
document.getElementById("shareLinkDisplay").style.display = "block";
} else {
showToast(t("error_generating_share") + ": " + (data.error||"Unknown"));
}
})
.catch(err => {
console.error(err);
showToast(t("error_generating_share"));
});
.then(res => res.json())
.then(data => {
if (data.token) {
const url = `${window.location.origin}/api/file/share.php?token=${encodeURIComponent(data.token)}`;
document.getElementById("shareLinkInput").value = url;
document.getElementById("shareLinkDisplay").style.display = "block";
} else {
showToast(t("error_generating_share") + ": " + (data.error || "Unknown"));
}
})
.catch(err => {
console.error(err);
showToast(t("error_generating_share"));
});
});
// Copy to clipboard
@@ -272,10 +278,7 @@ export function previewFile(fileUrl, fileName) {
modal.galleryCurrentIndex = (modal.galleryCurrentIndex - 1 + modal.galleryImages.length) % modal.galleryImages.length;
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
modal.querySelector("h4").textContent = newFile.name;
img.src = ((window.currentFolder === "root")
? "uploads/"
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
img.src = buildPreviewUrl(window.currentFolder || 'root', newFile.name);
// Reset transforms.
img.dataset.scale = 1;
img.dataset.rotate = 0;
@@ -355,10 +358,7 @@ export function previewFile(fileUrl, fileName) {
modal.galleryCurrentIndex = (modal.galleryCurrentIndex + 1) % modal.galleryImages.length;
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
modal.querySelector("h4").textContent = newFile.name;
img.src = ((window.currentFolder === "root")
? "uploads/"
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
img.src = buildPreviewUrl(window.currentFolder || 'root', newFile.name);
// Reset transforms.
img.dataset.scale = 1;
img.dataset.rotate = 0;
@@ -416,26 +416,26 @@ export function previewFile(fileUrl, fileName) {
}
} else {
// Handle non-image file previews.
if (extension === "pdf") {
// build a cachebusted URL
const separator = fileUrl.includes('?') ? '&' : '?';
const urlWithTs = fileUrl + separator + 't=' + Date.now();
// open in a new tab (avoids CSP frame-ancestors)
window.open(urlWithTs, "_blank");
// tear down the just-created modal
const modal = document.getElementById("filePreviewModal");
if (modal) modal.remove();
// stop further preview logic
return;
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) {
if (extension === "pdf") {
// build a cachebusted URL
const separator = fileUrl.includes('?') ? '&' : '?';
const urlWithTs = fileUrl + separator + 't=' + Date.now();
// open in a new tab (avoids CSP frame-ancestors)
window.open(urlWithTs, "_blank");
// tear down the just-created modal
const modal = document.getElementById("filePreviewModal");
if (modal) modal.remove();
// stop further preview logic
return;
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) {
const video = document.createElement("video");
video.src = fileUrl;
video.controls = true;
video.className = "image-modal-img";
const progressKey = 'videoProgress-' + fileUrl;
video.addEventListener("loadedmetadata", () => {
const savedTime = localStorage.getItem(progressKey);

View File

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

View File

@@ -1,11 +1,11 @@
// folderManager.js
import { loadFileList } from './fileListView.js';
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
import { t } from './i18n.js';
import { openFolderShareModal } from './folderShareModal.js';
import { fetchWithCsrf } from './auth.js';
import { loadCsrfToken } from './main.js';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { openFolderShareModal } from './folderShareModal.js?v={{APP_QVER}}';
import { fetchWithCsrf } from './auth.js?v={{APP_QVER}}';
import { loadCsrfToken } from './appCore.js?v={{APP_QVER}}';
/* ----------------------
Helpers: safe JSON + state
@@ -86,26 +86,27 @@ export function getParentFolder(folder) {
Breadcrumb Functions
----------------------*/
function setControlEnabled(el, enabled) {
if (!el) return;
if ('disabled' in el) el.disabled = !enabled;
el.classList.toggle('disabled', !enabled);
el.setAttribute('aria-disabled', String(!enabled));
el.style.pointerEvents = enabled ? '' : 'none';
el.style.opacity = enabled ? '' : '0.5';
}
async function applyFolderCapabilities(folder) {
try {
const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' });
if (!res.ok) return;
const caps = await res.json();
const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' });
if (!res.ok) return;
const caps = await res.json();
window.currentFolderCaps = caps;
// top buttons
const createBtn = document.getElementById('createFolderBtn');
const renameBtn = document.getElementById('renameFolderBtn');
const deleteBtn = document.getElementById('deleteFolderBtn');
const shareBtn = document.getElementById('shareFolderBtn');
if (createBtn) createBtn.disabled = !caps.canCreate;
if (renameBtn) renameBtn.disabled = !caps.canRename || folder === 'root';
if (deleteBtn) deleteBtn.disabled = !caps.canDelete || folder === 'root';
if (shareBtn) shareBtn.disabled = !caps.canShare || folder === 'root';
// keep for later if you want context menu to reflect caps
window.currentFolderCaps = caps;
} catch {}
const isRoot = (folder === 'root');
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder);
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete);
setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder);
}
// --- Breadcrumb Delegation Setup ---
@@ -146,6 +147,7 @@ function breadcrumbClickHandler(e) {
document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
if (target) target.classList.add("selected");
applyFolderCapabilities(window.currentFolder);
loadFileList(folder);
}
@@ -179,6 +181,49 @@ function breadcrumbDropHandler(e) {
console.error("Invalid drag data on breadcrumb:", err);
return;
}
/* FOLDER MOVE FALLBACK */
if (!dragData) {
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
if (plain) {
const sourceFolder = String(plain).trim();
if (sourceFolder && sourceFolder !== "root") {
if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) {
showToast("Invalid destination.", 4000);
return;
}
fetchWithCsrf("/api/folder/moveFolder.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
})
.then(safeJson)
.then(data => {
if (data && !data.error) {
showToast(`Folder moved to ${dropFolder}!`);
if (window.currentFolder && (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
const base = sourceFolder.split("/").pop();
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
window.currentFolder = newPath;
}
return loadFolderTree().then(() => {
try { expandTreePath(window.currentFolder || "root"); } catch (_) {}
loadFileList(window.currentFolder || "root");
});
} else {
showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
}
})
.catch(err => {
console.error("Error moving folder:", err);
showToast("Error moving folder", 5000);
});
}
}
return;
}
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
if (filesToMove.length === 0) return;
@@ -261,7 +306,7 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
} else {
html += `<span class="folder-indent-placeholder"></span>`;
}
html += `<span class="folder-option" data-folder="${fullPath}">${escapeHTML(folder)}</span>`;
html += `<span class="folder-option" draggable="true" data-folder="${fullPath}">${escapeHTML(folder)}</span>`;
if (hasChildren) {
html += renderFolderTree(tree[folder], fullPath, displayState);
}
@@ -311,13 +356,58 @@ function folderDropHandler(event) {
event.preventDefault();
event.currentTarget.classList.remove("drop-hover");
const dropFolder = event.currentTarget.getAttribute("data-folder");
let dragData;
let dragData = null;
try {
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
} catch (e) {
const jsonStr = event.dataTransfer.getData("application/json") || "";
if (jsonStr) dragData = JSON.parse(jsonStr);
}
catch (e) {
console.error("Invalid drag data", e);
return;
}
/* FOLDER MOVE FALLBACK */
if (!dragData) {
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
if (plain) {
const sourceFolder = String(plain).trim();
if (sourceFolder && sourceFolder !== "root") {
if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) {
showToast("Invalid destination.", 4000);
return;
}
fetchWithCsrf("/api/folder/moveFolder.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
})
.then(safeJson)
.then(data => {
if (data && !data.error) {
showToast(`Folder moved to ${dropFolder}!`);
if (window.currentFolder && (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
const base = sourceFolder.split("/").pop();
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
window.currentFolder = newPath;
}
return loadFolderTree().then(() => {
try { expandTreePath(window.currentFolder || "root"); } catch (_) {}
loadFileList(window.currentFolder || "root");
});
} else {
showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
}
})
.catch(err => {
console.error("Error moving folder:", err);
showToast("Error moving folder", 5000);
});
}
}
return;
}
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
if (filesToMove.length === 0) return;
@@ -458,6 +548,14 @@ export async function loadFolderTree(selectedFolder) {
// Attach drag/drop event listeners.
container.querySelectorAll(".folder-option").forEach(el => {
// Provide folder path payload for folder->folder DnD
el.addEventListener("dragstart", (ev) => {
const src = el.getAttribute("data-folder");
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {}
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {}
ev.dataTransfer.effectAllowed = "move";
});
el.addEventListener("dragover", folderDragOverHandler);
el.addEventListener("dragleave", folderDragLeaveHandler);
el.addEventListener("drop", folderDropHandler);
@@ -486,6 +584,14 @@ export async function loadFolderTree(selectedFolder) {
// Folder-option click: update selection, breadcrumbs, and file list
container.querySelectorAll(".folder-option").forEach(el => {
// Provide folder path payload for folder->folder DnD
el.addEventListener("dragstart", (ev) => {
const src = el.getAttribute("data-folder");
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {}
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {}
ev.dataTransfer.effectAllowed = "move";
});
el.addEventListener("click", function (e) {
e.stopPropagation();
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
@@ -641,6 +747,44 @@ if (submitRename) {
});
}
// === Move Folder Modal helper (shared by button + context menu) ===
function openMoveFolderUI(sourceFolder) {
const modal = document.getElementById('moveFolderModal');
const targetSel = document.getElementById('moveFolderTarget');
// If you right-clicked a different folder than currently selected, use that
if (sourceFolder && sourceFolder !== 'root') {
window.currentFolder = sourceFolder;
}
// Fill target dropdown
if (targetSel) {
targetSel.innerHTML = '';
fetch('/api/folder/getFolderList.php', { credentials: 'include' })
.then(r => r.json())
.then(list => {
if (Array.isArray(list) && list.length && typeof list[0] === 'object' && list[0].folder) {
list = list.map(it => it.folder);
}
// Root option
const rootOpt = document.createElement('option');
rootOpt.value = 'root'; rootOpt.textContent = '(Root)';
targetSel.appendChild(rootOpt);
(list || [])
.filter(f => f && f !== 'trash' && f !== (window.currentFolder || ''))
.forEach(f => {
const o = document.createElement('option');
o.value = f; o.textContent = f;
targetSel.appendChild(o);
});
})
.catch(()=>{ /* no-op */ });
}
if (modal) modal.style.display = 'block';
}
export function openDeleteFolderModal() {
const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") {
@@ -824,6 +968,7 @@ function folderManagerContextMenuHandler(e) {
const folder = target.getAttribute("data-folder");
if (!folder) return;
window.currentFolder = folder;
applyFolderCapabilities(window.currentFolder);
// Visual selection
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
@@ -839,6 +984,10 @@ function folderManagerContextMenuHandler(e) {
if (input) input.focus();
}
},
{
label: t("move_folder"),
action: () => { openMoveFolderUI(folder); }
},
{
label: t("rename_folder"),
action: () => { openRenameFolderModal(); }
@@ -921,4 +1070,53 @@ document.addEventListener("DOMContentLoaded", function () {
});
// Initial context menu delegation bind
bindFolderManagerContextMenu();
bindFolderManagerContextMenu();
document.addEventListener("DOMContentLoaded", () => {
const moveBtn = document.getElementById('moveFolderBtn');
const modal = document.getElementById('moveFolderModal');
const targetSel = document.getElementById('moveFolderTarget');
const cancelBtn = document.getElementById('cancelMoveFolder');
const confirmBtn= document.getElementById('confirmMoveFolder');
if (moveBtn) {
moveBtn.addEventListener('click', () => {
const cf = window.currentFolder || 'root';
if (!cf || cf === 'root') { showToast('Select a non-root folder to move.'); return; }
openMoveFolderUI(cf);
});
}
if (cancelBtn) cancelBtn.addEventListener('click', () => { if (modal) modal.style.display = 'none'; });
if (confirmBtn) confirmBtn.addEventListener('click', async () => {
if (!targetSel) return;
const destination = targetSel.value;
const source = window.currentFolder;
if (!destination) { showToast('Pick a destination'); return; }
if (destination === source || (destination + '/').startsWith(source + '/')) {
showToast('Invalid destination'); return;
}
try {
const res = await fetch('/api/folder/moveFolder.php', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken },
body: JSON.stringify({ source, destination })
});
const data = await safeJson(res);
if (res.ok && data && !data.error) {
showToast('Folder moved');
if (modal) modal.style.display='none';
await loadFolderTree();
const base = source.split('/').pop();
const newPath = (destination === 'root' ? '' : destination + '/') + base;
window.currentFolder = newPath;
loadFileList(window.currentFolder || 'root');
} else {
showToast('Error: ' + (data && data.error || 'Move failed'));
}
} catch (e) { console.error(e); showToast('Move failed'); }
});
});

View File

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

View File

@@ -216,6 +216,7 @@ const translations = {
"spanish": "Spanish",
"french": "French",
"german": "German",
"chinese_simplified": "Chinese (Simplified)",
"use_totp_code_instead": "Use TOTP Code instead",
"submit_recovery_code": "Submit Recovery Code",
"please_enter_recovery_code": "Please enter your recovery code.",
@@ -246,7 +247,7 @@ const translations = {
"login_options": "Login Options",
"disable_login_form": "Disable Login Form",
"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",
"at_least_one_login_method": "At least one login method must remain enabled.",
"settings_updated_successfully": "Settings updated successfully.",
@@ -275,7 +276,33 @@ const translations = {
"newfile_placeholder": "New file name",
"file_created_successfully": "File created successfully!",
"error_creating_file": "Error creating file",
"file_created": "File created successfully!"
"file_created": "File created successfully!",
"no_access_to_resource": "You do not have access to this resource.",
"can_share": "Can Share",
"bypass_ownership": "Bypass Ownership",
"error_loading_user_grants": "Error loading user grants",
"click_to_edit": "Click to edit",
"folder_access": "Folder Access",
"move_folder": "Move Folder",
"move_folder_message": "Select a destination folder to move this folder to:",
"move_folder_title": "Move this folder",
"move_folder_success": "Folder moved successfully.",
"move_folder_error": "Error moving folder.",
"move_folder_invalid": "Invalid source or destination folder.",
"move_folder_denied": "You do not have permission to move this folder.",
"move_folder_same_dest": "Destination cannot be the source or one of its subfolders.",
"move_folder_same_owner": "Source and destination must have the same owner.",
"move_folder_confirm": "Are you sure you want to move this folder?",
"move_folder_select_dest": "Select a destination folder",
"move_folder_select_dest_help": "Choose where this folder should be moved to.",
"acl_move_folder_label": "Move Folder (source)",
"acl_move_folder_help": "Allows moving this folder to a different parent. Requires Manage or Ownership on the folder.",
"acl_move_in_label": "Allow Moves Into This Folder (destination)",
"acl_move_in_help": "Allows items or folders from elsewhere to be moved into this folder. Requires Manage on the destination folder.",
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
"context_move_folder": "Move Folder...",
"context_move_here": "Move Here",
"context_move_cancel": "Cancel Move"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
@@ -458,6 +485,7 @@ const translations = {
"spanish": "Español",
"french": "Francés",
"german": "Alemán",
"chinese_simplified": "Chino (simplificado)",
"use_totp_code_instead": "Usar código TOTP en su lugar",
"submit_recovery_code": "Enviar código de recuperación",
"please_enter_recovery_code": "Por favor, ingrese su código de recuperación.",
@@ -686,6 +714,7 @@ const translations = {
"spanish": "Espagnol",
"french": "Français",
"german": "Allemand",
"chinese_simplified": "Chinois (simplifié)",
"use_totp_code_instead": "Utiliser le code TOTP à la place",
"submit_recovery_code": "Soumettre le code de récupération",
"please_enter_recovery_code": "Veuillez entrer votre code de récupération.",
@@ -923,6 +952,7 @@ const translations = {
"spanish": "Spanisch",
"french": "Französisch",
"german": "Deutsch",
"chinese_simplified": "Chinesisch (vereinfacht)",
"use_totp_code_instead": "Stattdessen TOTP-Code verwenden",
"submit_recovery_code": "Wiederherstellungscode absenden",
"please_enter_recovery_code": "Bitte geben Sie Ihren Wiederherstellungscode ein.",
@@ -972,7 +1002,275 @@ const translations = {
"show": "Zeige",
"items_per_page": "elemente pro seite",
"columns": "Spalten"
},
"zh-CN": {
"please_log_in_to_continue": "请登录以继续。",
"no_files_selected": "未选择文件。",
"confirm_delete_files": "确定要删除所选的 {count} 个文件吗?",
"element_not_found": "未找到 ID 为 \"{id}\" 的元素。",
"search_placeholder": "搜索文件、标签和上传者…",
"search_placeholder_advanced": "高级搜索:文件、标签、上传者和内容…",
"basic_search_tooltip": "基础搜索:按文件名、标签和上传者搜索。",
"advanced_search_tooltip": "高级搜索:包括文件内容、文件名、标签和上传者。",
"file_name": "文件名",
"date_modified": "修改日期",
"upload_date": "上传日期",
"file_size": "文件大小",
"uploader": "上传者",
"enter_totp_code": "输入 TOTP 验证码",
"use_recovery_code_instead": "改用恢复代码",
"enter_recovery_code": "输入恢复代码",
"editing": "正在编辑",
"decrease_font": "A-",
"increase_font": "A+",
"save": "保存",
"close": "关闭",
"no_files_found": "未找到文件。",
"switch_to_table_view": "切换到表格视图",
"switch_to_gallery_view": "切换到图库视图",
"share_file": "分享文件",
"set_expiration": "设置到期时间:",
"password_optional": "密码(可选):",
"generate_share_link": "生成分享链接",
"shareable_link": "可分享链接:",
"copy_link": "复制链接",
"tag_file": "标记文件",
"tag_name": "标签名称:",
"tag_color": "标签颜色:",
"save_tag": "保存标签",
"light_mode": "浅色模式",
"dark_mode": "深色模式",
"upload_instruction": "将文件/文件夹拖到此处,或点击“选择文件”",
"no_files_selected_default": "未选择文件",
"choose_files": "选择文件",
"delete_selected": "删除所选",
"copy_selected": "复制所选",
"move_selected": "移动所选",
"tag_selected": "标记所选",
"download_zip": "下载 ZIP",
"extract_zip": "解压 ZIP",
"preview": "预览",
"edit": "编辑",
"rename": "重命名",
"trash_empty": "回收站为空。",
"no_trash_selected": "未选择要还原的回收站项目。",
"title": "FileRise",
"header_title": "FileRise",
"header_title_text": "标题文本",
"logout": "退出登录",
"change_password": "更改密码",
"restore_text": "还原或",
"delete_text": "删除回收站项目",
"restore_selected": "还原所选",
"restore_all": "全部还原",
"delete_selected_trash": "删除所选",
"delete_all": "全部删除",
"upload_header": "上传文件/文件夹",
"folder_navigation": "文件夹导航与管理",
"create_folder": "创建文件夹",
"create_folder_title": "创建文件夹",
"enter_folder_name": "输入文件夹名称",
"cancel": "取消",
"create": "创建",
"rename_folder": "重命名文件夹",
"rename_folder_title": "重命名文件夹",
"rename_folder_placeholder": "输入新的文件夹名称",
"delete_folder": "删除文件夹",
"delete_folder_title": "删除文件夹",
"delete_folder_message": "确定要删除此文件夹吗?",
"folder_help": "文件夹帮助",
"folder_help_item_1": "点击文件夹以查看其中的文件。",
"folder_help_item_2": "使用 [-] 折叠,使用 [+] 展开文件夹。",
"folder_help_item_3": "选择一个文件夹并点击“创建文件夹”以添加子文件夹。",
"folder_help_item_4": "要重命名或删除文件夹,请选择后点击相应按钮。",
"actions": "操作",
"file_list_title": "文件列表(根目录)",
"files_in": "文件位于",
"delete_files": "删除文件",
"delete_selected_files_title": "删除所选文件",
"delete_files_message": "确定要删除所选文件吗?",
"copy_files": "复制文件",
"copy_files_title": "复制所选文件",
"copy_files_message": "选择目标文件夹以复制所选文件:",
"move_files": "移动文件",
"move_files_title": "移动所选文件",
"move_files_message": "选择目标文件夹以移动所选文件:",
"move": "移动",
"extract_zip_button": "解压 ZIP",
"download_zip_title": "将所选文件打包为 ZIP 下载",
"download_zip_prompt": "输入 ZIP 文件名:",
"zip_placeholder": "files.zip",
"share": "分享",
"total_files": "文件总数",
"total_size": "总大小",
"prev": "上一页",
"next": "下一页",
"page": "第",
"of": "页,共",
"login": "登录",
"remember_me": "记住我",
"login_oidc": "使用 OIDC 登录",
"basic_http_login": "使用基本 HTTP 登录",
"change_password_title": "更改密码",
"old_password": "旧密码",
"new_password": "新密码",
"confirm_new_password": "确认新密码",
"create_new_user_title": "创建新用户",
"username": "用户名:",
"password": "密码:",
"enter_password": "密码",
"preparing_download": "正在准备下载…",
"download_file": "下载文件",
"confirm_or_change_filename": "确认或修改下载文件名:",
"filename": "文件名",
"download": "下载",
"grant_admin": "授予管理员权限",
"save_user": "保存用户",
"remove_user_title": "删除用户",
"select_user_remove": "选择要删除的用户:",
"delete_user": "删除用户",
"rename_file_title": "重命名文件",
"rename_file_placeholder": "输入新的文件名",
"share_folder": "分享文件夹",
"allow_uploads": "允许上传",
"share_link_generated": "已生成分享链接",
"error_generating_share_link": "生成分享链接时出错",
"custom": "自定义",
"duration": "持续时间",
"seconds": "秒",
"minutes": "分钟",
"hours": "小时",
"days": "天",
"custom_duration_warning": "⚠️ 使用较长的到期时间可能存在安全风险,请谨慎使用。",
"folder_share": "分享文件夹",
"yes": "是",
"no": "否",
"unsaved_changes_confirm": "您有未保存的更改,确定要关闭而不保存吗?",
"delete": "删除",
"upload": "上传",
"copy": "复制",
"extract": "解压",
"user": "用户:",
"unknown_error": "未知错误",
"link_copied": "链接已复制到剪贴板",
"weeks": "周",
"months": "月",
"dark_mode_toggle": "深色模式",
"light_mode_toggle": "浅色模式",
"switch_to_light_mode": "切换到浅色模式",
"switch_to_dark_mode": "切换到深色模式",
"header_settings": "标题设置",
"shared_max_upload_size_bytes_title": "共享最大上传大小",
"shared_max_upload_size_bytes": "共享最大上传大小(字节)",
"max_bytes_shared_uploads_note": "请输入共享文件夹上传的最大允许字节数",
"manage_shared_links": "管理分享链接",
"folder_shares": "文件夹分享",
"file_shares": "文件分享",
"loading": "正在加载…",
"error_loading_share_links": "加载分享链接时出错",
"share_deleted_successfully": "分享已成功删除",
"error_deleting_share": "删除分享时出错",
"password_protected": "受密码保护",
"no_shared_links_available": "暂无可用的分享链接",
"admin_panel": "管理员面板",
"user_panel": "用户面板",
"user_settings": "用户设置",
"save_profile_picture": "保存头像",
"please_select_picture": "请选择图片",
"profile_picture_updated": "头像已更新",
"error_updating_picture": "更新头像时出错",
"trash_restore_delete": "回收站恢复/删除",
"totp_settings": "TOTP 设置",
"enable_totp": "启用 TOTP",
"language": "语言",
"select_language": "选择语言",
"english": "英语",
"spanish": "西班牙语",
"french": "法语",
"german": "德语",
"chinese_simplified": "简体中文",
"use_totp_code_instead": "改用 TOTP 验证码",
"submit_recovery_code": "提交恢复代码",
"please_enter_recovery_code": "请输入您的恢复代码。",
"recovery_code_verification_failed": "恢复代码验证失败",
"error_verifying_recovery_code": "验证恢复代码时出错",
"totp_verification_failed": "TOTP 验证失败",
"error_verifying_totp_code": "验证 TOTP 代码时出错",
"totp_setup": "TOTP 设置",
"scan_qr_code": "请使用验证器应用扫描此二维码。",
"enter_totp_confirmation": "输入应用生成的 6 位验证码以确认设置:",
"confirm": "确认",
"please_enter_valid_code": "请输入有效的 6 位验证码。",
"totp_enabled_successfully": "TOTP 启用成功。",
"error_generating_recovery_code": "生成恢复代码时出错",
"error_loading_qr_code": "加载二维码时出错。",
"error_disabling_totp_setting": "禁用 TOTP 设置时出错",
"user_management": "用户管理",
"add_user": "添加用户",
"remove_user": "删除用户",
"user_permissions": "用户权限",
"oidc_configuration": "OIDC 配置",
"oidc_provider_url": "OIDC 提供者 URL",
"oidc_client_id": "OIDC 客户端 ID",
"oidc_client_secret": "OIDC 客户端密钥",
"oidc_redirect_uri": "OIDC 重定向 URI",
"global_totp_settings": "全局 TOTP 设置",
"global_otpauth_url": "全局 OTPAuth URL",
"login_options": "登录选项",
"disable_login_form": "禁用登录表单",
"disable_basic_http_auth": "禁用基本 HTTP 认证",
"disable_oidc_login": "禁用 OIDC 登录",
"save_settings": "保存设置",
"at_least_one_login_method": "至少保留一种登录方式。",
"settings_updated_successfully": "设置已成功更新。",
"error_updating_settings": "更新设置时出错",
"user_permissions_updated_successfully": "用户权限已成功更新。",
"error_updating_permissions": "更新权限时出错",
"no_users_found": "未找到用户。",
"user_folder_only": "仅限用户文件夹",
"read_only": "只读",
"disable_upload": "禁用上传",
"error_loading_users": "加载用户时出错",
"save_permissions": "保存权限",
"your_recovery_code": "您的恢复代码",
"please_save_recovery_code": "请妥善保存此代码。此代码仅显示一次且只能使用一次。",
"ok": "确定",
"show": "显示",
"items_per_page": "每页项目数",
"columns": "列",
"row_height": "行高",
"api_docs": "API 文档",
"show_folders_above_files": "在文件上方显示文件夹",
"display": "显示",
"create_file": "创建文件",
"create_new_file": "创建新文件",
"enter_file_name": "输入文件名",
"newfile_placeholder": "新文件名",
"file_created_successfully": "文件创建成功!",
"error_creating_file": "创建文件时出错",
"file_created": "文件创建成功!",
"no_access_to_resource": "您无权访问此资源。",
"can_share": "可分享",
"bypass_ownership": "绕过所有权限制",
"error_loading_user_grants": "加载用户授权时出错",
"click_to_edit": "点击编辑",
"folder_access": "文件夹访问"
}
};
let currentLocale = 'en';

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
// trashRestoreDelete.js
import { sendRequest } from './networkUtils.js';
import { toggleVisibility, showToast } from './domUtils.js';
import { loadFileList } from './fileListView.js';
import { loadFolderTree } from './folderManager.js';
import { t } from './i18n.js';
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
function showConfirm(message, onConfirm) {
const modal = document.getElementById("customConfirmModal");

View File

@@ -1,9 +1,9 @@
import { initFileActions } from './fileActions.js';
import { displayFilePreview } from './filePreview.js';
import { showToast, escapeHTML } from './domUtils.js';
import { loadFolderTree } from './folderManager.js';
import { loadFileList } from './fileListView.js';
import { t } from './i18n.js';
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
/* -----------------------------------------------------
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.
function getFilesFromDataTransferItems(items) {
const promises = [];
@@ -161,91 +193,91 @@ function createFileEntry(file) {
const removeBtn = document.createElement("button");
removeBtn.classList.add("remove-file-btn");
removeBtn.textContent = "×";
// In your remove button event listener, replace the fetch call with:
removeBtn.addEventListener("click", function (e) {
e.stopPropagation();
const uploadIndex = file.uploadIndex;
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
// Cancel the file upload if possible.
if (typeof file.cancel === "function") {
file.cancel();
console.log("Canceled file upload:", file.fileName);
}
// Remove file from the resumable queue.
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
resumableInstance.removeFile(file);
}
// Call our helper repeatedly to remove the chunk folder.
if (file.uniqueIdentifier) {
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
}
li.remove();
updateFileInfoCount();
});
// In your remove button event listener, replace the fetch call with:
removeBtn.addEventListener("click", function (e) {
e.stopPropagation();
const uploadIndex = file.uploadIndex;
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
// Cancel the file upload if possible.
if (typeof file.cancel === "function") {
file.cancel();
console.log("Canceled file upload:", file.fileName);
}
// Remove file from the resumable queue.
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
resumableInstance.removeFile(file);
}
// Call our helper repeatedly to remove the chunk folder.
if (file.uniqueIdentifier) {
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
}
li.remove();
updateFileInfoCount();
});
li.removeBtn = removeBtn;
li.appendChild(removeBtn);
// Add pause/resume/restart button if the file supports pause/resume.
// Conditionally add the pause/resume button only if file.pause is available
// Pause/Resume button (for resumable filepicker uploads)
if (typeof file.pause === "function") {
const pauseResumeBtn = document.createElement("button");
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
pauseResumeBtn.classList.add("pause-resume-btn");
// Start with pause icon and disable button until upload starts
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
pauseResumeBtn.disabled = true;
pauseResumeBtn.addEventListener("click", function (e) {
e.stopPropagation();
if (file.isError) {
// If the file previously failed, try restarting upload.
if (typeof file.retry === "function") {
file.retry();
file.isError = false;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
}
} else if (!file.paused) {
// Pause the upload (if possible)
if (typeof file.pause === "function") {
file.pause();
file.paused = true;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
} else {
}
} else if (file.paused) {
// Resume sequence: first call to resume (or upload() fallback)
if (typeof file.resume === "function") {
file.resume();
} else {
resumableInstance.upload();
}
// After a short delay, pause again then resume
setTimeout(() => {
// Conditionally add the pause/resume button only if file.pause is available
// Pause/Resume button (for resumable filepicker uploads)
if (typeof file.pause === "function") {
const pauseResumeBtn = document.createElement("button");
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
pauseResumeBtn.classList.add("pause-resume-btn");
// Start with pause icon and disable button until upload starts
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
pauseResumeBtn.disabled = true;
pauseResumeBtn.addEventListener("click", function (e) {
e.stopPropagation();
if (file.isError) {
// If the file previously failed, try restarting upload.
if (typeof file.retry === "function") {
file.retry();
file.isError = false;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
}
} else if (!file.paused) {
// Pause the upload (if possible)
if (typeof file.pause === "function") {
file.pause();
file.paused = true;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
} else {
}
} else if (file.paused) {
// Resume sequence: first call to resume (or upload() fallback)
if (typeof file.resume === "function") {
file.resume();
} else {
resumableInstance.upload();
}
// After a short delay, pause again then resume
setTimeout(() => {
if (typeof file.resume === "function") {
file.resume();
if (typeof file.pause === "function") {
file.pause();
} else {
resumableInstance.upload();
}
setTimeout(() => {
if (typeof file.resume === "function") {
file.resume();
} else {
resumableInstance.upload();
}
}, 100);
}, 100);
}, 100);
file.paused = false;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
} else {
console.error("Pause/resume function not available for file", file);
}
});
li.appendChild(pauseResumeBtn);
}
file.paused = false;
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
} else {
console.error("Pause/resume function not available for file", file);
}
});
li.appendChild(pauseResumeBtn);
}
// Preview element
const preview = document.createElement("div");
@@ -401,29 +433,49 @@ function processFiles(filesInput) {
Resumable.js Integration for File Picker Uploads
(Only files chosen via file input use Resumable; folder uploads use original code.)
----------------------------------------------------- */
const useResumable = true; // Enable resumable for file picker uploads
let resumableInstance;
function initResumableUpload() {
resumableInstance = new Resumable({
target: "/api/upload/upload.php",
query: { folder: window.currentFolder || "root", upload_token: window.csrfToken },
chunkSize: 1.5 * 1024 * 1024, // 1.5 MB chunks
simultaneousUploads: 3,
forceChunkSize: true,
testChunks: false,
throttleProgressCallbacks: 1,
withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken },
query: {
folder: window.currentFolder || "root",
upload_token: window.csrfToken // still as a fallback
}
const useResumable = true;
let resumableInstance = null;
let _pendingPickedFiles = []; // files picked before library/instance ready
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",
chunkSize: 1.5 * 1024 * 1024,
simultaneousUploads: 3,
forceChunkSize: true,
testChunks: false,
withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken },
query: () => ({
folder: window.currentFolder || "root",
upload_token: window.csrfToken
})
});
}
// keep query fresh when folder changes (call this from your folder nav code)
function updateResumableQuery() {
if (!resumableInstance) return;
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
resumableInstance.opts.query.folder = window.currentFolder || 'root';
resumableInstance.opts.query.upload_token = window.csrfToken;
}
const fileInput = document.getElementById("file");
if (fileInput) {
// Assign Resumable to file input for file picker uploads.
resumableInstance.assignBrowse(fileInput);
fileInput.addEventListener("change", function () {
for (let i = 0; i < fileInput.files.length; i++) {
resumableInstance.addFile(fileInput.files[i]);
@@ -432,6 +484,7 @@ function initResumableUpload() {
}
resumableInstance.on("fileAdded", function (file) {
// Initialize custom paused flag
file.paused = false;
file.uploadIndex = file.uniqueIdentifier;
@@ -461,16 +514,17 @@ function initResumableUpload() {
li.dataset.uploadIndex = file.uniqueIdentifier;
list.appendChild(li);
updateFileInfoCount();
updateResumableQuery();
});
resumableInstance.on("fileProgress", function(file) {
resumableInstance.on("fileProgress", function (file) {
const progress = file.progress(); // value between 0 and 1
const percent = Math.floor(progress * 100);
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
if (li && li.progressBar) {
if (percent < 99) {
li.progressBar.style.width = percent + "%";
// Calculate elapsed time and speed.
const elapsed = (Date.now() - li.startTime) / 1000;
let speed = "";
@@ -491,7 +545,7 @@ function initResumableUpload() {
li.progressBar.style.width = "100%";
li.progressBar.innerHTML = '<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
}
// Enable the pause/resume button once progress starts.
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) {
@@ -499,8 +553,8 @@ function initResumableUpload() {
}
}
});
resumableInstance.on("fileSuccess", function(file, message) {
resumableInstance.on("fileSuccess", function (file, message) {
// Try to parse JSON response
let data;
try {
@@ -508,18 +562,18 @@ function initResumableUpload() {
} catch (e) {
data = null;
}
// 1) Softfail CSRF? then update token & retry this file
if (data && data.csrf_expired) {
// Update global and Resumable headers
window.csrfToken = data.csrf_token;
resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token;
resumableInstance.opts.query.upload_token = data.csrf_token;
resumableInstance.opts.query.upload_token = data.csrf_token;
// Retry this chunk/file
file.retry();
return;
return;
}
// 2) Otherwise treat as real success:
const li = document.querySelector(
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
@@ -531,13 +585,13 @@ function initResumableUpload() {
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
const removeBtn = li.querySelector(".remove-file-btn");
if (removeBtn) removeBtn.style.display = "none";
if (removeBtn) removeBtn.style.display = "none";
setTimeout(() => li.remove(), 5000);
}
loadFileList(window.currentFolder);
});
resumableInstance.on("fileError", function (file, message) {
@@ -578,13 +632,24 @@ function initResumableUpload() {
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
----------------------------------------------------- */
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 fileInput = document.getElementById("file");
@@ -637,7 +702,7 @@ function submitFiles(allFiles) {
} catch (e) {
jsonResponse = null;
}
// ─── Soft-fail CSRF: retry this upload ───────────────────────
if (jsonResponse && jsonResponse.csrf_expired) {
console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
@@ -650,10 +715,10 @@ function submitFiles(allFiles) {
xhr.send(formData);
return; // skip the "finishedCount++" and error/success logic for now
}
// ─── Normal success/error handling ────────────────────────────
const li = progressElements[file.uploadIndex];
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
// real success
if (li) {
@@ -662,6 +727,7 @@ function submitFiles(allFiles) {
if (li.removeBtn) li.removeBtn.style.display = "none";
}
uploadResults[file.uploadIndex] = true;
} else {
// real failure
if (li) {
@@ -681,12 +747,17 @@ function submitFiles(allFiles) {
}
}, 5000);
}
// ─── Only now count this chunk as finished ───────────────────
finishedCount++;
if (finishedCount === allFiles.length) {
refreshFileList(allFiles, uploadResults, progressElements);
}
if (finishedCount === allFiles.length) {
const succeededCount = uploadResults.filter(Boolean).length;
const failedCount = allFiles.length - succeededCount;
setTimeout(() => {
refreshFileList(allFiles, uploadResults, progressElements);
}, 250);
}
});
xhr.addEventListener("error", function () {
@@ -699,6 +770,9 @@ function submitFiles(allFiles) {
finishedCount++;
if (finishedCount === allFiles.length) {
refreshFileList(allFiles, uploadResults, progressElements);
// Immediate summary toast based on actual XHR outcomes
const succeededCount = uploadResults.filter(Boolean).length;
const failedCount = allFiles.length - succeededCount;
}
});
@@ -725,17 +799,30 @@ function submitFiles(allFiles) {
loadFileList(folderToUse)
.then(serverFiles => {
initFileActions();
serverFiles = (serverFiles || []).map(item => item.name.trim().toLowerCase());
// Be tolerant to API shapes: string or object with name/fileName/filename
serverFiles = (serverFiles || [])
.map(item => {
if (typeof item === 'string') return item;
const n = item?.name ?? item?.fileName ?? item?.filename ?? '';
return String(n);
})
.map(s => s.trim().toLowerCase())
.filter(Boolean);
let overallSuccess = true;
let succeeded = 0;
allFiles.forEach(file => {
const clientFileName = file.name.trim().toLowerCase();
const li = progressElements[file.uploadIndex];
if (!uploadResults[file.uploadIndex] || !serverFiles.includes(clientFileName)) {
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
if (!uploadResults[file.uploadIndex] || (!hadRelative && !serverFiles.includes(clientFileName))) {
if (li) {
li.progressBar.innerText = "Error";
}
overallSuccess = false;
} else if (li) {
succeeded++;
// Schedule removal of successful file entry after 5 seconds.
setTimeout(() => {
li.remove();
@@ -757,9 +844,12 @@ function submitFiles(allFiles) {
}, 5000);
}
});
if (!overallSuccess) {
showToast("Some files failed to upload. Please check the list.");
const failed = allFiles.length - succeeded;
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
} else {
showToast(`${succeeded} file succeeded. Please check the list.`);
}
})
.catch(error => {
@@ -768,6 +858,7 @@ function submitFiles(allFiles) {
})
.finally(() => {
loadFolderTree(window.currentFolder);
});
}
}
@@ -822,32 +913,48 @@ function initUpload() {
}
if (fileInput) {
fileInput.addEventListener("change", function () {
fileInput.addEventListener("change", async function () {
const files = Array.from(fileInput.files || []);
if (!files.length) return;
if (useResumable) {
// For file picker, if resumable is enabled, let it handle the files.
for (let i = 0; i < fileInput.files.length; i++) {
resumableInstance.addFile(fileInput.files[i]);
// Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
for (const f of files) resumableInstance.addFile(f);
} else {
// If still not ready (load error), fall back to your XHR path
processFiles(files);
}
} else {
processFiles(fileInput.files);
processFiles(files);
}
});
}
if (uploadForm) {
uploadForm.addEventListener("submit", function (e) {
uploadForm.addEventListener("submit", async function (e) {
e.preventDefault();
const files = window.selectedFiles || (fileInput ? fileInput.files : []);
if (!files || files.length === 0) {
if (!files || !files.length) {
showToast("No files selected.");
return;
}
// If files come from file picker (no relative path), use Resumable.
if (useResumable && (!files[0].customRelativePath || files[0].customRelativePath === "")) {
// Ensure current folder is updated.
resumableInstance.opts.query.folder = window.currentFolder || "root";
resumableInstance.upload();
showToast("Resumable upload started...");
// Resumable path (only for picked files, not folder uploads)
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.upload();
showToast("Resumable upload started...");
} else {
// fallback
submitFiles(files);
}
} else {
submitFiles(files);
}

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

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

21
public/vendor/bootstrap/4.5.2/LICENSE vendored Normal file
View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

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

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

File diff suppressed because one or more lines are too long

1
public/vendor/dompurify/2.4.0/purify.min.js-2.map generated vendored Normal file

File diff suppressed because one or more lines are too long

180
public/vendor/fuse/6.6.2/LICENSE vendored Normal file
View File

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

9
public/vendor/fuse/6.6.2/fuse.min.js vendored Normal file

File diff suppressed because one or more lines are too long

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

21
public/vendor/resumable/1.1.0/LICENSE vendored Normal file
View File

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

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 KiB

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 736 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 608 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 KiB

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 554 KiB

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

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

View File

@@ -70,7 +70,10 @@ class AuthController
if ($oidcAction === 'callback') {
try {
$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
$totp_secret = null;

View File

@@ -65,6 +65,37 @@ class FileController
return [];
}
private static function folderOfPath(string $path): string {
// normalize path to folder; files: use dirname, folders: return path
$p = trim(str_replace('\\', '/', $path), "/ \t\r\n");
if ($p === '' || $p === 'root') return 'root';
// If it ends with a slash or is an existing folder path, treat as folder
if (substr($p, -1) === '/') $p = rtrim($p, '/');
// For files, take the parent folder
$dir = dirname($p);
return ($dir === '.' || $dir === '') ? 'root' : $dir;
}
private static function ensureSrcDstAllowedForCopy(
string $user, array $perms, string $srcPath, string $dstFolder
): bool {
$srcFolder = ACL::normalizeFolder(self::folderOfPath($srcPath));
$dstFolder = ACL::normalizeFolder($dstFolder);
// Need to be able to see the source (own or full) and copy into destination
return ACL::canReadOwn($user, $perms, $srcFolder)
&& ACL::canCopy($user, $perms, $dstFolder);
}
private static function ensureSrcDstAllowedForMove(
string $user, array $perms, string $srcPath, string $dstFolder
): bool {
$srcFolder = ACL::normalizeFolder(self::folderOfPath($srcPath));
$dstFolder = ACL::normalizeFolder($dstFolder);
// Move removes from source and adds to dest
return ACL::canDelete($user, $perms, $srcFolder)
&& ACL::canMove($user, $perms, $dstFolder);
}
/**
* Ownership-only enforcement for a set of files in a folder.
* Returns null if OK, or an error string.
@@ -135,21 +166,26 @@ class FileController
// Otherwise, require the specific capability on the target folder
$ok = false;
switch ($need) {
case 'manage':
$ok = ACL::canManage($username, $userPermissions, $folder);
break;
case 'write':
$ok = ACL::canWrite($username, $userPermissions, $folder);
break;
case 'share':
$ok = ACL::canShare($username, $userPermissions, $folder);
break;
case 'read_own':
$ok = ACL::canReadOwn($username, $userPermissions, $folder);
break;
default: // 'read'
$ok = ACL::canRead($username, $userPermissions, $folder);
}
case 'manage': $ok = ACL::canManage($username, $userPermissions, $folder); break;
case 'write': $ok = ACL::canWrite($username, $userPermissions, $folder); break; // legacy
case 'share': $ok = ACL::canShare($username, $userPermissions, $folder); break; // legacy
case 'read_own': $ok = ACL::canReadOwn($username, $userPermissions, $folder); break;
// granular:
case 'create': $ok = ACL::canCreate($username, $userPermissions, $folder); break;
case 'upload': $ok = ACL::canUpload($username, $userPermissions, $folder); break;
case 'edit': $ok = ACL::canEdit($username, $userPermissions, $folder); break;
case 'rename': $ok = ACL::canRename($username, $userPermissions, $folder); break;
case 'copy': $ok = ACL::canCopy($username, $userPermissions, $folder); break;
case 'move': $ok = ACL::canMove($username, $userPermissions, $folder); break;
case 'delete': $ok = ACL::canDelete($username, $userPermissions, $folder); break;
case 'extract': $ok = ACL::canExtract($username, $userPermissions, $folder); break;
case 'shareFile':
case 'share_file': $ok = ACL::canShareFile($username, $userPermissions, $folder); break;
case 'shareFolder':
case 'share_folder': $ok = ACL::canShareFolder($username, $userPermissions, $folder); break;
default: // 'read'
$ok = ACL::canRead($username, $userPermissions, $folder);
}
return $ok ? null : "Forbidden: folder scope violation.";
}
@@ -209,110 +245,157 @@ class FileController
* Actions
* ========================= */
public function copyFiles()
{
$this->_jsonStart();
try {
if (!$this->_checkCsrf()) return;
if (!$this->_requireAuth()) return;
public function copyFiles()
{
$this->_jsonStart();
try {
if (!$this->_checkCsrf()) return;
if (!$this->_requireAuth()) return;
$data = $this->_readJsonBody();
if (
!$data
|| !isset($data['source'], $data['destination'], $data['files'])
|| !is_array($data['files'])
) {
$this->_jsonOut(["error" => "Invalid request"], 400); return;
}
$sourceFolder = $this->_normalizeFolder($data['source']);
$destinationFolder = $this->_normalizeFolder($data['destination']);
$files = array_values(array_filter(array_map('basename', (array)$data['files'])));
$data = $this->_readJsonBody();
if (!$data || !isset($data['source'], $data['destination'], $data['files']) || !is_array($data['files'])) {
$this->_jsonOut(["error" => "Invalid request"], 400); return;
if (!$this->_validFolder($sourceFolder) || !$this->_validFolder($destinationFolder)) {
$this->_jsonOut(["error" => "Invalid folder name(s)."], 400); return;
}
if (empty($files)) {
$this->_jsonOut(["error" => "No files specified."], 400); return;
}
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
// --- Permission gates (granular) ------------------------------------
// Source: own-only view is enough to copy (we'll enforce ownership below if no full read)
$hasSourceView = ACL::canReadOwn($username, $userPermissions, $sourceFolder)
|| $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions);
if (!$hasSourceView) {
$this->_jsonOut(["error" => "Forbidden: no read access to source"], 403); return;
}
// Destination: must have 'copy' capability (or own ancestor)
$hasDestCreate = ACL::canCreate($username, $userPermissions, $destinationFolder)
|| $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions);
if (!$hasDestCreate) {
$this->_jsonOut(["error" => "Forbidden: no write access to destination"], 403); return;
}
$sourceFolder = $this->_normalizeFolder($data['source']);
$destinationFolder = $this->_normalizeFolder($data['destination']);
$files = $data['files'];
$needSrcScope = ACL::canRead($username, $userPermissions, $sourceFolder) ? 'read' : 'read_own';
// Folder-scope checks with the needed capabilities
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, $needSrcScope);
if ($sv) { $this->_jsonOut(["error" => $sv], 403); return; }
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'create');
if ($dv) { $this->_jsonOut(["error" => $dv], 403); return; }
// If the user doesn't have full read on source (only read_own), enforce per-file ownership
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
if (
!$ignoreOwnership
&& !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read
&& ACL::hasGrant($username, $sourceFolder, 'read_own') // but has own-only
) {
$ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
if ($ownErr) { $this->_jsonOut(["error" => $ownErr], 403); return; }
}
if (!$this->_validFolder($sourceFolder) || !$this->_validFolder($destinationFolder)) {
$this->_jsonOut(["error" => "Invalid folder name(s)."], 400); return;
}
// Account flags: copy writes new objects into destination
if (!empty($userPermissions['readOnly'])) {
$this->_jsonOut(["error" => "Account is read-only."], 403); return;
}
if (!empty($userPermissions['disableUpload'])) {
$this->_jsonOut(["error" => "Uploads are disabled for your account."], 403); return;
}
// --- Do the copy ----------------------------------------------------
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
$this->_jsonOut($result);
} catch (Throwable $e) {
error_log('FileController::copyFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while copying files.'], 500);
} finally {
$this->_jsonEnd();
}
}
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
public function deleteFiles()
{
$this->_jsonStart();
try {
if (!$this->_checkCsrf()) return;
if (!$this->_requireAuth()) return;
// Gate: need read on source (or ancestor-owner) and write on destination (or ancestor-owner)
if (!(ACL::canRead($username, $userPermissions, $sourceFolder) || $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions))) {
$this->_jsonOut(["error"=>"Forbidden: no read access to source"], 403); return;
}
if (!(ACL::canWrite($username, $userPermissions, $destinationFolder) || $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions))) {
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
}
$data = $this->_readJsonBody();
if (!is_array($data) || !isset($data['files']) || !is_array($data['files'])) {
$this->_jsonOut(["error" => "No file names provided"], 400); return;
}
// Folder-scope checks with the needed capabilities
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'read');
if ($sv) { $this->_jsonOut(["error"=>$sv], 403); return; }
// sanitize/normalize the list (empty names filtered out)
$files = array_values(array_filter(array_map('strval', $data['files']), fn($s) => $s !== ''));
if (!$files) {
$this->_jsonOut(["error" => "No file names provided"], 400); return;
}
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'write');
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
$folder = $this->_normalizeFolder($data['folder'] ?? 'root');
if (!$this->_validFolder($folder)) {
$this->_jsonOut(["error" => "Invalid folder name."], 400); return;
}
// If the user doesn't have full read on source (only read_own), enforce per-file ownership
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
if (
!$ignoreOwnership
&& !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read
&& ACL::hasGrant($username, $sourceFolder, 'read_own') // but has own-only
) {
$ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
if ($ownErr) { $this->_jsonOut(["error"=>$ownErr], 403); return; }
}
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
$this->_jsonOut($result);
} catch (Throwable $e) {
error_log('FileController::copyFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while copying files.'], 500);
} finally { $this->_jsonEnd(); }
}
// --- Permission gates (granular) ------------------------------------
// Need delete on folder (or ancestor-owner)
$hasDelete = ACL::canDelete($username, $userPermissions, $folder)
|| $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
if (!$hasDelete) {
$this->_jsonOut(["error" => "Forbidden: no delete permission"], 403); return;
}
public function deleteFiles()
{
$this->_jsonStart();
try {
if (!$this->_checkCsrf()) return;
if (!$this->_requireAuth()) return;
// --- Folder-scope check (granular) ----------------------------------
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'delete');
if ($dv) { $this->_jsonOut(["error" => $dv], 403); return; }
$data = $this->_readJsonBody();
if (!isset($data['files']) || !is_array($data['files'])) {
$this->_jsonOut(["error" => "No file names provided"], 400); return;
}
// --- Ownership enforcement when user only has viewOwn ----------------
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
$isFolderOwner = ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
$folder = $this->_normalizeFolder($data['folder'] ?? 'root');
if (!$this->_validFolder($folder)) {
$this->_jsonOut(["error" => "Invalid folder name."], 400); return;
}
// If user is not owner/admin and does NOT have full view, but does have own-only, enforce per-file ownership
if (
!$ignoreOwnership
&& !$isFolderOwner
&& !ACL::canRead($username, $userPermissions, $folder) // lacks full read
&& ACL::hasGrant($username, $folder, 'read_own') // has own-only
) {
$ownErr = $this->enforceScopeAndOwnership($folder, $files, $username, $userPermissions);
if ($ownErr) { $this->_jsonOut(["error" => $ownErr], 403); return; }
}
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
// --- Perform delete --------------------------------------------------
$result = FileModel::deleteFiles($folder, $files);
$this->_jsonOut($result);
// Need write (or ancestor-owner)
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
}
// Folder-scope: write
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
// Ownership enforcement for non-admins who are not folder owners
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
$isFolderOwner = ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
if (!$ignoreOwnership && !$isFolderOwner) {
$violation = $this->enforceScopeAndOwnership($folder, $data['files'], $username, $userPermissions);
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
}
$result = FileModel::deleteFiles($folder, $data['files']);
$this->_jsonOut($result);
} catch (Throwable $e) {
error_log('FileController::deleteFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while deleting files.'], 500);
} finally { $this->_jsonEnd(); }
}
} catch (Throwable $e) {
error_log('FileController::deleteFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while deleting files.'], 500);
} finally { $this->_jsonEnd(); }
}
public function moveFiles()
{
@@ -320,41 +403,59 @@ class FileController
try {
if (!$this->_checkCsrf()) return;
if (!$this->_requireAuth()) return;
$data = $this->_readJsonBody();
if (!$data || !isset($data['source'], $data['destination'], $data['files']) || !is_array($data['files'])) {
if (
!$data
|| !isset($data['source'], $data['destination'], $data['files'])
|| !is_array($data['files'])
) {
$this->_jsonOut(["error" => "Invalid request"], 400); return;
}
$sourceFolder = $this->_normalizeFolder($data['source']);
$destinationFolder = $this->_normalizeFolder($data['destination']);
if (!$this->_validFolder($sourceFolder) || !$this->_validFolder($destinationFolder)) {
$this->_jsonOut(["error" => "Invalid folder name(s)."], 400); return;
}
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
// Require write on both ends (or ancestor-owner on each end)
if (!(ACL::canWrite($username, $userPermissions, $sourceFolder) || $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions))) {
$this->_jsonOut(["error"=>"Forbidden: no write access to source"], 403); return;
$files = $data['files'];
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
// --- Permission gates (granular) ------------------------------------
// Must be able to at least SEE the source and DELETE there
$hasSourceView = ACL::canReadOwn($username, $userPermissions, $sourceFolder)
|| $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions);
if (!$hasSourceView) {
$this->_jsonOut(["error" => "Forbidden: no read access to source"], 403); return;
}
if (!(ACL::canWrite($username, $userPermissions, $destinationFolder) || $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions))) {
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
$hasSourceDelete = ACL::canDelete($username, $userPermissions, $sourceFolder)
|| $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions);
if (!$hasSourceDelete) {
$this->_jsonOut(["error" => "Forbidden: no delete permission on source"], 403); return;
}
$files = $data['files'];
// Folder scope: need WRITE on both ends for a move
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'write');
if ($sv) { $this->_jsonOut(["error"=>$sv], 403); return; }
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'write');
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
// If the user doesn't have full read on source (only read_own), enforce per-file ownership
// Destination must allow MOVE
$hasDestMove = ACL::canMove($username, $userPermissions, $destinationFolder)
|| $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions);
if (!$hasDestMove) {
$this->_jsonOut(["error" => "Forbidden: no move permission on destination"], 403); return;
}
// --- Folder-scope checks --------------------------------------------
// Source needs 'delete' scope; destination needs 'move' scope
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'delete');
if ($sv) { $this->_jsonOut(["error" => $sv], 403); return; }
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'move');
if ($dv) { $this->_jsonOut(["error" => $dv], 403); return; }
// --- Ownership enforcement when only viewOwn on source --------------
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
if (
!$ignoreOwnership
&& !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read
@@ -363,13 +464,17 @@ class FileController
$ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
if ($ownErr) { $this->_jsonOut(["error"=>$ownErr], 403); return; }
}
// --- Perform move ----------------------------------------------------
$result = FileModel::moveFiles($sourceFolder, $destinationFolder, $files);
$this->_jsonOut($result);
} catch (Throwable $e) {
error_log('FileController::moveFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while moving files.'], 500);
} finally { $this->_jsonEnd(); }
} finally {
$this->_jsonEnd();
}
}
public function renameFile()
@@ -378,12 +483,12 @@ class FileController
try {
if (!$this->_checkCsrf()) return;
if (!$this->_requireAuth()) return;
$data = $this->_readJsonBody();
if (!$data || !isset($data['folder'], $data['oldName'], $data['newName'])) {
$this->_jsonOut(["error" => "Invalid input"], 400); return;
}
$folder = $this->_normalizeFolder($data['folder']);
$oldName = basename(trim((string)$data['oldName']));
$newName = basename(trim((string)$data['newName']));
@@ -391,19 +496,19 @@ class FileController
if (!$this->_validFile($oldName) || !$this->_validFile($newName)) {
$this->_jsonOut(["error"=>"Invalid file name(s)."], 400); return;
}
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
// Need write (or ancestor-owner)
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
// Need granular rename (or ancestor-owner)
if (!(ACL::canRename($username, $userPermissions, $folder))) {
$this->_jsonOut(["error"=>"Forbidden: no rename rights"], 403); return;
}
// Folder scope: write
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
// Folder scope: rename
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'rename');
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
// Ownership for non-admins when not a folder owner
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
@@ -412,7 +517,7 @@ class FileController
$violation = $this->enforceScopeAndOwnership($folder, [$oldName], $username, $userPermissions);
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
}
$result = FileModel::renameFile($folder, $oldName, $newName);
if (!is_array($result)) throw new RuntimeException('FileModel::renameFile returned non-array');
if (isset($result['error'])) { $this->_jsonOut($result, 400); return; }
@@ -444,12 +549,12 @@ class FileController
$userPermissions = $this->loadPerms($username);
// Need write (or ancestor-owner)
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
if (!(ACL::canEdit($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
$this->_jsonOut(["error"=>"Forbidden: no full write access"], 403); return;
}
// Folder scope: write
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'edit');
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
// If overwriting, enforce ownership for non-admins (unless folder owner)
@@ -580,7 +685,7 @@ class FileController
$perms = $this->loadPerms($username);
// Optional zip gate by account flag
if (!$this->isAdmin($perms) && array_key_exists('canZip', $perms) && !$perms['canZip']) {
if (!$this->isAdmin($perms) && !empty($perms['disableZip'])) {
$this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403); return;
}
@@ -652,12 +757,12 @@ class FileController
$perms = $this->loadPerms($username);
// must be able to write into target folder (or be ancestor-owner)
if (!(ACL::canWrite($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms))) {
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
if (!(ACL::canExtract($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms))) {
$this->_jsonOut(["error"=>"Forbidden: no full write access to destination"], 403); return;
}
// Folder scope: write
$dv = $this->enforceFolderScope($folder, $username, $perms, 'write');
$dv = $this->enforceFolderScope($folder, $username, $perms, 'extract');
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
$result = FileModel::extractZipArchive($folder, $data['files']);
@@ -785,7 +890,7 @@ class FileController
$userPermissions = $this->loadPerms($username);
// Need share (or ancestor-owner)
if (!(ACL::canShare($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
if (!(ACL::canShareFile($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
$this->_jsonOut(["error"=>"Forbidden: no share access"], 403); return;
}
@@ -936,7 +1041,7 @@ class FileController
// Need write (or ancestor-owner)
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
$this->_jsonOut(["error"=>"Forbidden: no full write access"], 403); return;
}
// Folder scope: write
@@ -967,49 +1072,78 @@ class FileController
{
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json; charset=utf-8');
// convert warnings/notices to exceptions for cleaner error handling
set_error_handler(function ($severity, $message, $file, $line) {
if (!(error_reporting() & $severity)) return;
throw new ErrorException($message, 0, $severity, $file, $line);
});
try {
if (empty($_SESSION['username'])) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
return;
}
if (!is_dir(META_DIR)) @mkdir(META_DIR, 0775, true);
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
return;
}
if (!is_dir(UPLOAD_DIR)) {
http_response_code(500);
echo json_encode(['error' => 'Uploads directory not found.']);
return;
}
// --- inputs ---
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
// Validate folder path: allow "root" or nested segments that each match REGEX_FOLDER_NAME
if ($folder !== 'root') {
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
if (empty($parts)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
return;
}
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
return;
}
}
$folder = implode('/', $parts);
}
// ---- Folder-level view checks (full vs own-only) ----
$username = $_SESSION['username'] ?? '';
$perms = $this->loadPerms($username);
// Full view if read OR ancestor owner
$fullView = ACL::canRead($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms);
$fullView = ACL::canRead($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms);
$ownOnlyGrant = ACL::hasGrant($username, $folder, 'read_own');
if (!$fullView && !$ownOnlyGrant) {
// Special-case: keep Root visible but inert if user lacks any visibility there.
if ($folder === 'root' && !$fullView && !$ownOnlyGrant) {
echo json_encode([
'success' => true,
'folder' => 'root',
'files' => [],
// Optional hint the UI can use to show a soft message / disable actions:
'uiHints' => [
'noAccessRoot' => true,
'message' => "You don't have access to Root. Select a folder you have access to."
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
}
// Non-root: still enforce 403 if no visibility
if ($folder !== 'root' && !$fullView && !$ownOnlyGrant) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: no view access to this folder.']);
return;
}
// Fetch the list
$result = FileModel::getFileList($folder);
if ($result === false || $result === null) {
@@ -1025,12 +1159,12 @@ class FileController
echo json_encode($result);
return;
}
// ---- Apply own-only filter if user does NOT have full view ----
if (!$fullView && $ownOnlyGrant && isset($result['files'])) {
$files = $result['files'];
// If files keyed by filename
// If files keyed by filename (assoc array)
if (is_array($files) && array_keys($files) !== range(0, count($files) - 1)) {
$filtered = [];
foreach ($files as $name => $meta) {
@@ -1040,7 +1174,7 @@ class FileController
}
$result['files'] = $filtered;
}
// If files are a numeric array of metadata
// If files is a numeric array of metadata items
else if (is_array($files)) {
$result['files'] = array_values(array_filter(
$files,
@@ -1050,7 +1184,7 @@ class FileController
));
}
}
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
error_log('FileController::getFileList error: '.$e->getMessage().' in '.$e->getFile().':'.$e->getLine());
@@ -1117,12 +1251,12 @@ class FileController
$userPermissions = $this->loadPerms($username);
// Need write (or ancestor-owner)
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
if (!(ACL::canCreate($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
$this->_jsonOut(["error"=>"Forbidden: no full write access"], 403); return;
}
// Folder scope: write
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'create');
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
$result = FileModel::createFile($folder, $filename, $username);

View File

@@ -150,35 +150,65 @@ class FolderController
* $need: 'read' | 'write' | 'manage' | 'share' | 'read_own' (default 'read')
* Returns null if allowed, or an error string if forbidden.
*/
private static function enforceFolderScope(string $folder, string $username, array $perms, string $need = 'read'): ?string
{
// Admins bypass scope
if (self::isAdmin($perms)) return null;
// In FolderController.php
private static function enforceFolderScope(
string $folder,
string $username,
array $perms,
string $need = 'read'
): ?string {
// Admins bypass scope
if (self::isAdmin($perms)) return null;
// Not a folder-only account? no gate here
if (!self::isFolderOnly($perms)) return null;
// If this account isn't folder-scoped, don't gate here
if (!self::isFolderOnly($perms)) return null;
$folder = ACL::normalizeFolder($folder);
$folder = ACL::normalizeFolder($folder);
// If user owns folder or an ancestor, allow
$f = $folder;
while ($f !== '' && strtolower($f) !== 'root') {
if (ACL::isOwner($username, $perms, $f)) return null;
$pos = strrpos($f, '/');
$f = ($pos === false) ? '' : substr($f, 0, $pos);
}
// Otherwise, require specific capability on the target folder
switch ($need) {
case 'manage': $ok = ACL::canManage($username, $perms, $folder); break;
case 'write': $ok = ACL::canWrite($username, $perms, $folder); break;
case 'share': $ok = ACL::canShare($username, $perms, $folder); break;
case 'read_own': $ok = ACL::canReadOwn($username, $perms, $folder);break;
default: $ok = ACL::canRead($username, $perms, $folder);
}
return $ok ? null : "Forbidden: folder scope violation.";
// If user owns folder or an ancestor, allow
$f = $folder;
while ($f !== '' && strtolower($f) !== 'root') {
if (ACL::isOwner($username, $perms, $f)) return null;
$pos = strrpos($f, '/');
$f = ($pos === false) ? '' : substr($f, 0, $pos);
}
// Normalize aliases so callers can pass either camelCase or snake_case
switch ($need) {
case 'manage': $ok = ACL::canManage($username, $perms, $folder); break;
// legacy:
case 'write': $ok = ACL::canWrite($username, $perms, $folder); break;
case 'share': $ok = ACL::canShare($username, $perms, $folder); break;
// read flavors:
case 'read_own': $ok = ACL::canReadOwn($username, $perms, $folder); break;
case 'read': $ok = ACL::canRead($username, $perms, $folder); break;
// granular write-ish:
case 'create': $ok = ACL::canCreate($username, $perms, $folder); break;
case 'upload': $ok = ACL::canUpload($username, $perms, $folder); break;
case 'edit': $ok = ACL::canEdit($username, $perms, $folder); break;
case 'rename': $ok = ACL::canRename($username, $perms, $folder); break;
case 'copy': $ok = ACL::canCopy($username, $perms, $folder); break;
case 'move': $ok = ACL::canMove($username, $perms, $folder); break;
case 'delete': $ok = ACL::canDelete($username, $perms, $folder); break;
case 'extract': $ok = ACL::canExtract($username, $perms, $folder); break;
// granular share (support both key styles)
case 'shareFile':
case 'share_file': $ok = ACL::canShareFile($username, $perms, $folder); break;
case 'shareFolder':
case 'share_folder':$ok = ACL::canShareFolder($username, $perms, $folder); break;
default:
// Default to full read if unknown need was passed
$ok = ACL::canRead($username, $perms, $folder);
}
return $ok ? null : "Forbidden: folder scope violation.";
}
/** Returns true if caller can ignore ownership (admin or bypassOwnership/default). */
private static function canBypassOwnership(array $perms): bool
{
@@ -194,49 +224,58 @@ class FolderController
/* -------------------- API: Create Folder -------------------- */
public function createFolder(): void
{
header('Content-Type: application/json');
self::requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit; }
self::requireCsrf();
self::requireNotReadOnly();
{
header('Content-Type: application/json');
self::requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); return; }
self::requireCsrf();
self::requireNotReadOnly();
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['folderName'])) { http_response_code(400); echo json_encode(['error' => 'Folder name not provided.']); exit; }
try {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
if (!isset($input['folderName'])) { http_response_code(400); echo json_encode(['error' => 'Folder name not provided.']); return; }
$folderName = trim((string)$input['folderName']);
$parentIn = isset($input['parent']) ? trim((string)$input['parent']) : '';
$parentIn = isset($input['parent']) ? trim((string)$input['parent']) : 'root';
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
http_response_code(400); echo json_encode(['error' => 'Invalid folder name.']); exit;
http_response_code(400); echo json_encode(['error' => 'Invalid folder name.']); return;
}
if ($parentIn !== '' && strcasecmp($parentIn, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parentIn)) {
http_response_code(400); echo json_encode(['error' => 'Invalid parent folder name.']); exit;
http_response_code(400); echo json_encode(['error' => 'Invalid parent folder name.']); return;
}
// Normalize parent to an ACL key
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
$parent = ($parentIn === '' ? 'root' : $parentIn);
$username = $_SESSION['username'] ?? '';
$perms = self::getPerms();
// Must be able to write into parent OR be owner (or ancestor owner) of it
if (!(ACL::canWrite($username, $perms, $parent) || self::ownsFolderOrAncestor($parent, $username, $perms))) {
// Need create on parent OR ownership on parent/ancestor
if (!(ACL::canCreateFolder($username, $perms, $parent) || self::ownsFolderOrAncestor($parent, $username, $perms))) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: no write access to parent folder.']);
echo json_encode(['error' => 'Forbidden: manager/owner required on parent.']);
exit;
}
// Folder-scope gate for folder-only accounts (need write on parent)
if ($msg = self::enforceFolderScope($parent, $username, $perms, 'write')) {
http_response_code(403); echo json_encode(['error' => $msg]); exit;
// Folder-scope gate for folder-only accounts (need create on parent)
if ($msg = self::enforceFolderScope($parent, $username, $perms, 'manage')) {
http_response_code(403); echo json_encode(['error' => $msg]); return;
}
// Model should create folder and seed ACL (owner = creator)
$result = FolderModel::createFolder($folderName, $parent, $username);
if (empty($result['success'])) {
http_response_code(400);
echo json_encode($result);
return;
}
echo json_encode($result);
exit;
} catch (Throwable $e) {
error_log('createFolder fatal: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
http_response_code(500);
echo json_encode(['error' => 'Internal error creating folder.']);
}
}
/* -------------------- API: Delete Folder -------------------- */
public function deleteFolder(): void
@@ -307,11 +346,11 @@ class FolderController
http_response_code(403); echo json_encode(["error" => $msg]); exit;
}
// For the new folder path, require write scope (we're "creating" a path)
if ($msg = self::enforceFolderScope($newFolder, $username, $perms, 'write')) {
if ($msg = self::enforceFolderScope($newFolder, $username, $perms, 'manage')) {
http_response_code(403); echo json_encode(["error" => "New path not allowed: " . $msg]); exit;
}
// Strong gates: need manage on old OR ancestor owner; need write on new parent or ancestor owner
// Strong gates: need manage on old OR ancestor owner; need manage on new parent OR ancestor owner
$canManageOld = ACL::canManage($username, $perms, $oldFolder) || self::ownsFolderOrAncestor($oldFolder, $username, $perms);
if (!$canManageOld) {
http_response_code(403); echo json_encode(['error' => 'Forbidden: you lack manage rights on the source folder.']); exit;
@@ -656,4 +695,79 @@ for ($i = $startPage; $i <= $endPage; $i++): ?>
echo json_encode(['success' => false, 'error' => 'Not found']);
}
}
}
/* -------------------- API: Move Folder -------------------- */
public function moveFolder(): void
{
header('Content-Type: application/json; charset=utf-8');
self::requireAuth();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); echo json_encode(['error'=>'Method not allowed']); return; }
// CSRF: accept header or form field
$hdr = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
$tok = $_SESSION['csrf_token'] ?? '';
if (!$hdr || !$tok || !hash_equals((string)$tok, (string)$hdr)) { http_response_code(403); echo json_encode(['error'=>'Invalid CSRF token']); return; }
$raw = file_get_contents('php://input');
$input = json_decode($raw ?: "{}", true);
$source = trim((string)($input['source'] ?? ''));
$destination = trim((string)($input['destination'] ?? ''));
if ($source === '' || strcasecmp($source,'root')===0) { http_response_code(400); echo json_encode(['error'=>'Invalid source folder']); return; }
if ($destination === '') $destination = 'root';
// basic segment validation
foreach ([$source,$destination] as $f) {
if ($f==='root') continue;
$parts = array_filter(explode('/', trim($f, "/\\ ")), fn($p)=>$p!=='');
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) { http_response_code(400); echo json_encode(['error'=>'Invalid folder segment']); return; }
}
}
$srcNorm = trim($source, "/\\ ");
$dstNorm = $destination==='root' ? '' : trim($destination, "/\\ ");
// prevent move into self/descendant
if ($dstNorm !== '' && (strcasecmp($dstNorm,$srcNorm)===0 || strpos($dstNorm.'/', $srcNorm.'/')===0)) {
http_response_code(400); echo json_encode(['error'=>'Destination cannot be the source or its descendant']); return;
}
$username = $_SESSION['username'] ?? '';
$perms = $this->loadPerms($username);
// enforce scopes (source manage-ish, dest write-ish)
if ($msg = self::enforceFolderScope($source, $username, $perms, 'manage')) { http_response_code(403); echo json_encode(['error'=>$msg]); return; }
if ($msg = self::enforceFolderScope($destination, $username, $perms, 'write')) { http_response_code(403); echo json_encode(['error'=>$msg]); return; }
// Check capabilities using ACL helpers
$canManageSource = ACL::canManage($username, $perms, $source) || ACL::isOwner($username, $perms, $source);
$canMoveIntoDest = ACL::canMove($username, $perms, $destination) || ($destination==='root' ? self::isAdmin($perms) : ACL::isOwner($username, $perms, $destination));
if (!$canManageSource) { http_response_code(403); echo json_encode(['error'=>'Forbidden: manage rights required on source']); return; }
if (!$canMoveIntoDest) { http_response_code(403); echo json_encode(['error'=>'Forbidden: move rights required on destination']); return; }
// Non-admin: enforce same owner between source and destination tree (if any)
$isAdmin = self::isAdmin($perms);
if (!$isAdmin) {
try {
$ownerSrc = FolderModel::getOwnerFor($source) ?? '';
$ownerDst = $destination==='root' ? '' : (FolderModel::getOwnerFor($destination) ?? '');
if ($ownerSrc !== $ownerDst) {
http_response_code(403); echo json_encode(['error'=>'Source and destination must have the same owner']); return;
}
} catch (\Throwable $e) { /* ignore fall through */ }
}
// Compute final target "destination/basename(source)"
$baseName = basename(str_replace('\\','/', $srcNorm));
$target = $destination==='root' ? $baseName : rtrim($destination, "/\\ ") . '/' . $baseName;
try {
$result = FolderModel::renameFolder($source, $target);
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (\Throwable $e) {
error_log('moveFolder error: '.$e->getMessage());
http_response_code(500);
echo json_encode(['error'=>'Internal error moving folder']);
}
}
}

View File

@@ -52,57 +52,69 @@ class UploadController {
}
// ---- 3) Folder-level WRITE permission (ACL) ----
// Always require client to send the folder; fall back to GET if needed.
$folderParam = isset($_POST['folder']) ? (string)$_POST['folder'] : (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
$targetFolder = ACL::normalizeFolder($folderParam);
// Admins bypass folder canWrite checks
if (!$isAdmin && !ACL::canWrite($username, $userPerms, $targetFolder)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
return;
}
// ---- 4) Delegate to model (actual file/chunk processing) ----
// (Optionally re-check in UploadModel before finalizing.)
$result = UploadModel::handleUpload($_POST, $_FILES);
// ---- 5) Response ----
if (isset($result['error'])) {
http_response_code(400);
echo json_encode($result);
return;
}
if (isset($result['status'])) {
// e.g., {"status":"chunk uploaded"}
echo json_encode($result);
return;
}
echo json_encode([
'success' => 'File uploaded successfully',
'newFilename' => $result['newFilename'] ?? null
]);
// 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');
// Decode %xx (e.g., "test%20folder") then normalize
$folderParam = rawurldecode($folderParam);
$targetFolder = ACL::normalizeFolder($folderParam);
// Admins bypass folder canWrite checks
$username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms);
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
return;
}
// ---- 4) Delegate to model (force the sanitized folder) ----
$_POST['folder'] = $targetFolder; // in case model reads superglobal
$post = $_POST;
$post['folder'] = $targetFolder;
$result = UploadModel::handleUpload($post, $_FILES);
// ---- 5) Response (unchanged) ----
if (isset($result['error'])) {
http_response_code(400);
echo json_encode($result);
return;
}
if (isset($result['status'])) {
echo json_encode($result);
return;
}
echo json_encode([
'success' => 'File uploaded successfully',
'newFilename' => $result['newFilename'] ?? null
]);
}
public function removeChunks(): void {
header('Content-Type: application/json');
header('Content-Type: application/json');
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
return;
}
if (!isset($_POST['folder'])) {
http_response_code(400);
echo json_encode(['error' => 'No folder specified']);
return;
}
$folder = (string)$_POST['folder'];
$result = UploadModel::removeChunks($folder);
echo json_encode($result);
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
return;
}
if (!isset($_POST['folder'])) {
http_response_code(400);
echo json_encode(['error' => 'No folder specified']);
return;
}
$folderRaw = (string)$_POST['folder'];
$folder = ACL::normalizeFolder(rawurldecode($folderRaw));
echo json_encode(UploadModel::removeChunks($folder));
}
}

View File

@@ -3,6 +3,7 @@
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
/**
* UserController
@@ -665,4 +666,38 @@ class UserController
echo json_encode(['success' => true, 'url' => $url]);
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

@@ -6,23 +6,20 @@ require_once PROJECT_ROOT . '/config/config.php';
class ACL
{
/** In-memory cache of the ACL file. */
private static $cache = null;
/** Absolute path to folder_acl.json */
private static $path = null;
/** Capability buckets we store per folder. */
private const BUCKETS = ['owners','read','write','share','read_own']; // + read_own (view own only)
private const BUCKETS = [
'owners','read','write','share','read_own',
'create','upload','edit','rename','copy','move','delete','extract',
'share_file','share_folder'
];
/** Compute/cache the ACL storage path. */
private static function path(): string {
if (!self::$path) {
self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
}
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
return self::$path;
}
/** Normalize folder names (slashes + root). */
public static function normalizeFolder(string $f): string {
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
if ($f === '' || $f === 'root') return 'root';
@@ -33,23 +30,61 @@ class ACL
$user = (string)$user;
$acl = self::$cache ?? self::loadFresh();
$changed = false;
foreach ($acl['folders'] as $folder => &$rec) {
foreach (self::BUCKETS as $k) {
$before = $rec[$k] ?? [];
$before = is_array($rec[$k] ?? null) ? $rec[$k] : [];
$rec[$k] = array_values(array_filter($before, fn($u) => strcasecmp((string)$u, $user) !== 0));
if ($rec[$k] !== $before) $changed = true;
}
}
unset($rec);
return $changed ? self::save($acl) : true;
}
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
if (self::hasGrant($user, $folder, 'owners')) return true;
$folder = trim($folder, "/\\ ");
if ($folder === '' || $folder === 'root') return false;
$parts = explode('/', $folder);
while (count($parts) > 1) {
array_pop($parts);
$parent = implode('/', $parts);
if (self::hasGrant($user, $parent, 'owners')) return true;
}
return false;
}
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
public static function renameTree(string $oldFolder, string $newFolder): void
{
$old = self::normalizeFolder($oldFolder);
$new = self::normalizeFolder($newFolder);
if ($old === '' || $old === 'root') return; // nothing to re-key for root
$acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
$rebased = [];
foreach ($acl['folders'] as $k => $rec) {
if ($k === $old || strpos($k, $old . '/') === 0) {
$suffix = substr($k, strlen($old));
$suffix = ltrim((string)$suffix, '/');
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
$rebased[$newKey] = $rec;
} else {
$rebased[$k] = $rec;
}
}
$acl['folders'] = $rebased;
self::save($acl);
}
/** Load ACL fresh from disk, create/heal if needed. */
private static function loadFresh(): array {
$path = self::path();
if (!is_file($path)) {
@mkdir(dirname($path), 0755, true);
$init = [
@@ -59,7 +94,17 @@ class ACL
'read' => ['admin'],
'write' => ['admin'],
'share' => ['admin'],
'read_own'=> [], // new bucket; empty by default
'read_own'=> [],
'create' => [],
'upload' => [],
'edit' => [],
'rename' => [],
'copy' => [],
'move' => [],
'delete' => [],
'extract' => [],
'share_file' => [],
'share_folder' => [],
],
],
'groups' => [],
@@ -70,12 +115,9 @@ class ACL
$json = (string) @file_get_contents($path);
$data = json_decode($json, true);
if (!is_array($data)) $data = [];
// Normalize shape
$data['folders'] = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
$data['groups'] = isset($data['groups']) && is_array($data['groups']) ? $data['groups'] : [];
// Ensure root exists and has all buckets
if (!isset($data['folders']['root']) || !is_array($data['folders']['root'])) {
$data['folders']['root'] = [
'owners' => ['admin'],
@@ -84,16 +126,8 @@ class ACL
'share' => ['admin'],
'read_own' => [],
];
} else {
foreach (self::BUCKETS as $k) {
if (!isset($data['folders']['root'][$k]) || !is_array($data['folders']['root'][$k])) {
// sensible defaults: admin in the classic buckets, empty for read_own
$data['folders']['root'][$k] = ($k === 'read_own') ? [] : ['admin'];
}
}
}
// Heal any folder records
$healed = false;
foreach ($data['folders'] as $folder => &$rec) {
if (!is_array($rec)) { $rec = []; $healed = true; }
@@ -107,30 +141,22 @@ class ACL
unset($rec);
self::$cache = $data;
// Persist back if we healed anything
if ($healed) {
@file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
}
if ($healed) @file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
return $data;
}
/** Persist ACL to disk and refresh cache. */
private static function save(array $acl): bool {
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
if ($ok) self::$cache = $acl;
return $ok;
}
/** Get a bucket list (owners/read/write/share/read_own) for a folder (explicit only). */
private static function listFor(string $folder, string $key): array {
$acl = self::$cache ?? self::loadFresh();
$f = $acl['folders'][$folder] ?? null;
return is_array($f[$key] ?? null) ? $f[$key] : [];
}
/** Ensure a folder record exists (giving an initial owner). */
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void {
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
@@ -141,18 +167,26 @@ class ACL
'write' => [$owner],
'share' => [$owner],
'read_own' => [],
'create' => [],
'upload' => [],
'edit' => [],
'rename' => [],
'copy' => [],
'move' => [],
'delete' => [],
'extract' => [],
'share_file' => [],
'share_folder' => [],
];
self::save($acl);
}
}
/** True if this request is admin. */
public static function isAdmin(array $perms = []): bool {
if (!empty($_SESSION['isAdmin'])) return true;
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
// Optional: if you configured DEFAULT_ADMIN_USER, treat that username as admin
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
return true;
@@ -160,24 +194,19 @@ class ACL
return false;
}
/** Case-insensitive membership in a capability bucket. $cap: owner|owners|read|write|share|read_own */
public static function hasGrant(string $user, string $folder, string $cap): bool {
$folder = self::normalizeFolder($folder);
$capKey = ($cap === 'owner') ? 'owners' : $cap;
$arr = self::listFor($folder, $capKey);
foreach ($arr as $u) {
if (strcasecmp((string)$u, $user) === 0) return true;
}
foreach ($arr as $u) if (strcasecmp((string)$u, $user) === 0) return true;
return false;
}
/** True if user is an explicit owner (or admin). */
public static function isOwner(string $user, array $perms, string $folder): bool {
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners');
}
/** "Manage" in UI == owner. */
public static function canManage(string $user, array $perms, string $folder): bool {
return self::isOwner($user, $perms, $folder);
}
@@ -185,19 +214,15 @@ class ACL
public static function canRead(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
// IMPORTANT: write no longer implies read
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'read');
}
/** Own-only view = read_own OR (any full view). */
public static function canReadOwn(string $user, array $perms, string $folder): bool {
// if they can full-view, this is trivially true
if (self::canRead($user, $perms, $folder)) return true;
return self::hasGrant($user, $folder, 'read_own');
}
/** Upload = write OR owner. No bypassOwnership. */
public static function canWrite(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
@@ -205,7 +230,6 @@ class ACL
|| self::hasGrant($user, $folder, 'write');
}
/** Share = share OR owner. No bypassOwnership. */
public static function canShare(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
@@ -213,10 +237,7 @@ class ACL
|| self::hasGrant($user, $folder, 'share');
}
/**
* Return explicit lists for a folder (no inheritance).
* Keys: owners, read, write, share, read_own (always arrays).
*/
// Legacy-only explicit (to avoid breaking existing callers)
public static function explicit(string $folder): array {
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
@@ -235,10 +256,35 @@ class ACL
];
}
/**
* Upsert a full explicit record for a folder.
* NOTE: preserves existing 'read_own' so older callers don't wipe it.
*/
// New: full explicit including granular
public static function explicitAll(string $folder): array {
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
$rec = $acl['folders'][$folder] ?? [];
$norm = function ($v): array {
if (!is_array($v)) return [];
$v = array_map('strval', $v);
return array_values(array_unique($v));
};
return [
'owners' => $norm($rec['owners'] ?? []),
'read' => $norm($rec['read'] ?? []),
'write' => $norm($rec['write'] ?? []),
'share' => $norm($rec['share'] ?? []),
'read_own' => $norm($rec['read_own'] ?? []),
'create' => $norm($rec['create'] ?? []),
'upload' => $norm($rec['upload'] ?? []),
'edit' => $norm($rec['edit'] ?? []),
'rename' => $norm($rec['rename'] ?? []),
'copy' => $norm($rec['copy'] ?? []),
'move' => $norm($rec['move'] ?? []),
'delete' => $norm($rec['delete'] ?? []),
'extract' => $norm($rec['extract'] ?? []),
'share_file' => $norm($rec['share_file'] ?? []),
'share_folder' => $norm($rec['share_folder'] ?? []),
];
}
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
@@ -251,24 +297,23 @@ class ACL
'read' => $fmt($read),
'write' => $fmt($write),
'share' => $fmt($share),
// preserve any own-only grants unless caller explicitly manages them elsewhere
'read_own' => isset($existing['read_own']) && is_array($existing['read_own'])
? array_values(array_unique(array_map('strval', $existing['read_own'])))
: [],
'create' => isset($existing['create']) && is_array($existing['create']) ? array_values(array_unique(array_map('strval', $existing['create']))) : [],
'upload' => isset($existing['upload']) && is_array($existing['upload']) ? array_values(array_unique(array_map('strval', $existing['upload']))) : [],
'edit' => isset($existing['edit']) && is_array($existing['edit']) ? array_values(array_unique(array_map('strval', $existing['edit']))) : [],
'rename' => isset($existing['rename']) && is_array($existing['rename']) ? array_values(array_unique(array_map('strval', $existing['rename']))) : [],
'copy' => isset($existing['copy']) && is_array($existing['copy']) ? array_values(array_unique(array_map('strval', $existing['copy']))) : [],
'move' => isset($existing['move']) && is_array($existing['move']) ? array_values(array_unique(array_map('strval', $existing['move']))) : [],
'delete' => isset($existing['delete']) && is_array($existing['delete']) ? array_values(array_unique(array_map('strval', $existing['delete']))) : [],
'extract' => isset($existing['extract']) && is_array($existing['extract']) ? array_values(array_unique(array_map('strval', $existing['extract']))) : [],
'share_file' => isset($existing['share_file']) && is_array($existing['share_file']) ? array_values(array_unique(array_map('strval', $existing['share_file']))) : [],
'share_folder' => isset($existing['share_folder']) && is_array($existing['share_folder']) ? array_values(array_unique(array_map('strval', $existing['share_folder']))) : [],
];
return self::save($acl);
}
/**
* Atomic per-user update across many folders.
* $grants is like:
* [
* "folderA" => ["view"=>true, "viewOwn"=>false, "upload"=>true, "manage"=>false, "share"=>false],
* "folderB" => ["view"=>false, "viewOwn"=>true, "upload"=>false, "manage"=>false, "share"=>false],
* ]
* If a folder is INCLUDED with all false, the user is removed from all its buckets.
* (If the frontend omits a folder entirely, this method leaves that folder unchanged.)
*/
public static function applyUserGrantsAtomic(string $user, array $grants): array {
$user = (string)$user;
$path = self::path();
@@ -278,7 +323,6 @@ class ACL
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); }
try {
// Read current content
$raw = stream_get_contents($fh);
if ($raw === false) $raw = '';
$acl = json_decode($raw, true);
@@ -290,38 +334,59 @@ class ACL
foreach ($grants as $folder => $caps) {
$ff = self::normalizeFolder((string)$folder);
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) {
$acl['folders'][$ff] = ['owners'=>[], 'read'=>[], 'write'=>[], 'share'=>[], 'read_own'=>[]];
}
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
$rec =& $acl['folders'][$ff];
// Remove user from all buckets first (idempotent)
foreach (self::BUCKETS as $k) {
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
}
foreach (self::BUCKETS as $k) {
$arr = is_array($rec[$k]) ? $rec[$k] : [];
$rec[$k] = array_values(array_filter(
array_map('strval', $rec[$k]),
fn($u) => strcasecmp($u, $user) !== 0
array_map('strval', $arr),
fn($u) => strcasecmp((string)$u, $user) !== 0
));
}
$v = !empty($caps['view']); // full view
$vo = !empty($caps['viewOwn']); // own-only view
$u = !empty($caps['upload']);
$m = !empty($caps['manage']);
$s = !empty($caps['share']);
$v = !empty($caps['view']);
$vo = !empty($caps['viewOwn']);
$u = !empty($caps['upload']);
$m = !empty($caps['manage']);
$s = !empty($caps['share']);
$w = !empty($caps['write']);
// Implications
if ($m) { $v = true; $u = true; } // owner implies read+write
if ($u && !$v && !$vo) $vo = true; // upload needs at least own-only visibility
if ($s && !$v) $v = true; // sharing implies full read (can be relaxed if desired)
$c = !empty($caps['create']);
$ed = !empty($caps['edit']);
$rn = !empty($caps['rename']);
$cp = !empty($caps['copy']);
$mv = !empty($caps['move']);
$dl = !empty($caps['delete']);
$ex = !empty($caps['extract']);
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
// Add back per caps
if ($m) $rec['owners'][] = $user;
if ($v) $rec['read'][] = $user;
if ($vo) $rec['read_own'][]= $user;
if ($u) $rec['write'][] = $user;
if ($s) $rec['share'][] = $user;
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; }
if ($u && !$v && !$vo) $vo = true;
//if ($s && !$v) $v = true;
if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; }
if ($m) $rec['owners'][] = $user;
if ($v) $rec['read'][] = $user;
if ($vo) $rec['read_own'][] = $user;
if ($w) $rec['write'][] = $user;
if ($s) $rec['share'][] = $user;
if ($u) $rec['upload'][] = $user;
if ($c) $rec['create'][] = $user;
if ($ed) $rec['edit'][] = $user;
if ($rn) $rec['rename'][] = $user;
if ($cp) $rec['copy'][] = $user;
if ($mv) $rec['move'][] = $user;
if ($dl) $rec['delete'][] = $user;
if ($ex) $rec['extract'][] = $user;
if ($sf) $rec['share_file'][] = $user;
if ($sfo)$rec['share_folder'][] = $user;
// De-dup
foreach (self::BUCKETS as $k) {
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
}
@@ -330,7 +395,6 @@ class ACL
unset($rec);
}
// Write back atomically
ftruncate($fh, 0);
rewind($fh);
$ok = fwrite($fh, json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== false;
@@ -344,4 +408,96 @@ class ACL
fclose($fh);
}
}
}
// --- Granular write family -----------------------------------------------
public static function canCreate(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'create')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCreateFolder(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
// Only owners/managers can create subfolders under $folder
return self::hasGrant($user, $folder, 'owners');
}
public static function canUpload(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'upload')
|| self::hasGrant($user, $folder, 'write');
}
public static function canEdit(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'edit')
|| self::hasGrant($user, $folder, 'write');
}
public static function canRename(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'rename')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCopy(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'copy')
|| self::hasGrant($user, $folder, 'write');
}
public static function canMove(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canMoveFolder(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canDelete(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'delete')
|| self::hasGrant($user, $folder, 'write');
}
public static function canExtract(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'extract')
|| self::hasGrant($user, $folder, 'write');
}
/** Sharing: files use share, folders require share + full-view. */
public static function canShareFile(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
}
public static function canShareFolder(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
if (!$can) return false;
// require full view too
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'read');
}
}

View File

@@ -62,6 +62,51 @@ class AdminModel
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.
*
@@ -157,6 +202,14 @@ class AdminModel
// Best-effort normalize perms for host visibility (user rw, group rw)
@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."];
}
@@ -262,7 +315,7 @@ class AdminModel
],
'loginOptions' => [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableBasicAuth' => true,
'disableOIDCLogin' => true
],
'globalOtpauthUrl' => "",

View File

@@ -169,48 +169,67 @@ class FolderModel
* @param string $parent 'root' or nested key (e.g. 'team/reports')
* @param string $creator username to set as initial owner (falls back to 'admin')
*/
public static function createFolder(string $folderName, string $parent = 'root', string $creator = 'admin'): array
public static function createFolder(string $folderName, string $parent, string $creator): array
{
// -------- Normalize incoming values (use ONLY the parameters) --------
$folderName = trim((string)$folderName);
$parentIn = trim((string)$parent);
// If the client sent a path in folderName (e.g., "bob/new-sub") and parent is root/empty,
// derive parent = "bob" and folderName = "new-sub" so permission checks hit "bob".
$normalized = ACL::normalizeFolder($folderName);
if ($normalized !== 'root' && strpos($normalized, '/') !== false &&
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)) {
$parentIn = trim(str_replace('\\', '/', dirname($normalized)), '/');
$folderName = basename($normalized);
if ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) $parentIn = 'root';
}
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
$folderName = trim($folderName);
$parent = trim($parent);
if ($folderName === '' || !preg_match(REGEX_FOLDER_NAME, $folderName)) {
return ['success' => false, 'error' => 'Invalid folder name', 'code' => 400];
if ($folderName === '') return ['success'=>false, 'error' => 'Folder name required'];
// ACL key for new folder
$newKey = ($parent === 'root') ? $folderName : ($parent . '/' . $folderName);
// -------- Compose filesystem paths --------
$base = rtrim((string)UPLOAD_DIR, "/\\");
$parentRel = ($parent === 'root') ? '' : str_replace('/', DIRECTORY_SEPARATOR, $parent);
$parentAbs = $parentRel ? ($base . DIRECTORY_SEPARATOR . $parentRel) : $base;
$newAbs = $parentAbs . DIRECTORY_SEPARATOR . $folderName;
// -------- Exists / sanity checks --------
if (!is_dir($parentAbs)) return ['success'=>false, 'error' => 'Parent folder does not exist'];
if (is_dir($newAbs)) return ['success'=>false, 'error' => 'Folder already exists'];
// -------- Create directory --------
if (!@mkdir($newAbs, 0775, true)) {
$err = error_get_last();
return ['success'=>false, 'error' => 'Failed to create folder' . (!empty($err['message']) ? (': '.$err['message']) : '')];
}
if ($parent !== '' && strcasecmp($parent, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parent)) {
return ['success' => false, 'error' => 'Invalid parent folder', 'code' => 400];
// -------- Seed ACL --------
$inherit = defined('ACL_INHERIT_ON_CREATE') && ACL_INHERIT_ON_CREATE;
try {
if ($inherit) {
// Copy parents explicit (legacy 5 buckets), add creator to owners
$p = ACL::explicit($parent); // owners, read, write, share, read_own
$owners = array_values(array_unique(array_map('strval', array_merge($p['owners'], [$creator]))));
$read = $p['read'];
$write = $p['write'];
$share = $p['share'];
ACL::upsert($newKey, $owners, $read, $write, $share);
} else {
// Creator owns the new folder
ACL::ensureFolderRecord($newKey, $creator);
}
} catch (Throwable $e) {
// Roll back FS if ACL seeding fails
@rmdir($newAbs);
return ['success'=>false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
}
// Compute ACL key and filesystem path
$aclKey = ($parent === '' || strcasecmp($parent, 'root') === 0) ? $folderName : ($parent . '/' . $folderName);
$base = rtrim(UPLOAD_DIR, '/\\');
$path = ($parent === '' || strcasecmp($parent, 'root') === 0)
? $base . DIRECTORY_SEPARATOR . $folderName
: $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $parent) . DIRECTORY_SEPARATOR . $folderName;
// Safety: stay inside UPLOAD_DIR
$realBase = realpath($base);
$realPath = $path; // may not exist yet
$parentDir = dirname($path);
if (!is_dir($parentDir) && !@mkdir($parentDir, 0775, true)) {
return ['success' => false, 'error' => 'Failed to create parent path', 'code' => 500];
}
if (is_dir($path)) {
// Idempotent: still ensure ACL record exists
ACL::ensureFolderRecord($aclKey, $creator ?: 'admin');
return ['success' => true, 'folder' => $aclKey, 'alreadyExists' => true];
}
if (!@mkdir($path, 0775, true)) {
return ['success' => false, 'error' => 'Failed to create folder', 'code' => 500];
}
// Seed ACL: owner/read/write/share -> creator; read_own empty
ACL::ensureFolderRecord($aclKey, $creator ?: 'admin');
return ['success' => true, 'folder' => $aclKey];
return ['success' => true, 'folder' => $newKey];
}
@@ -307,6 +326,8 @@ class FolderModel
// Update ownership mapping for the entire subtree.
self::renameOwnersForTree($oldRel, $newRel);
// Re-key explicit ACLs for the moved subtree
ACL::renameTree($oldRel, $newRel);
return ["success" => true];
}

View File

@@ -4,211 +4,209 @@
require_once PROJECT_ROOT . '/config/config.php';
class UploadModel {
/**
* 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 {
// If this is a GET request for testing chunk existence.
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($post['resumableTest'])) {
$chunkNumber = intval($post['resumableChunkNumber']);
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
$baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
private static function sanitizeFolder(string $folder): string {
// decode "%20", normalise slashes & trim via ACL helper
$f = ACL::normalizeFolder(rawurldecode($folder));
// model uses '' to represent root
if ($f === 'root') return '';
// forbid dot segments / empty parts
foreach (explode('/', $f) as $seg) {
if ($seg === '' || $seg === '.' || $seg === '..') {
return '';
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
}
// 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
}
public static function handleUpload(array $post, array $files): array {
// --- GET resumable test (make folder handling consistent)
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($post['resumableTest'])) {
$chunkNumber = (int)($post['resumableChunkNumber'] ?? 0);
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
$baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
$chunkFile = $tempDir . $chunkNumber;
return ["status" => file_exists($chunkFile) ? "found" : "not found"];
}
// Handle chunked uploads.
// --- CHUNKED ---
if (isset($post['resumableChunkNumber'])) {
$chunkNumber = intval($post['resumableChunkNumber']);
$totalChunks = intval($post['resumableTotalChunks']);
$chunkNumber = (int)$post['resumableChunkNumber'];
$totalChunks = (int)$post['resumableTotalChunks'];
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
$resumableFilename = urldecode(basename($post['resumableFilename']));
// Validate file name.
$resumableFilename = urldecode(basename($post['resumableFilename'] ?? ''));
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
return ["error" => "Invalid file name: $resumableFilename"];
}
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name"];
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
if (empty($files['file']) || !isset($files['file']['name'])) {
return ["error" => "No files received"];
}
$baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
return ["error" => "Failed to create upload directory"];
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir) && !mkdir($tempDir, 0775, true)) {
return ["error" => "Failed to create temporary chunk directory"];
}
if (!isset($files["file"]) || $files["file"]["error"] !== UPLOAD_ERR_OK) {
$chunkErr = $files['file']['error'] ?? UPLOAD_ERR_NO_FILE;
if ($chunkErr !== UPLOAD_ERR_OK) {
return ["error" => "Upload error on chunk $chunkNumber"];
}
$chunkFile = $tempDir . $chunkNumber;
if (!move_uploaded_file($files["file"]["tmp_name"], $chunkFile)) {
$tmpName = $files['file']['tmp_name'] ?? null;
if (!$tmpName || !move_uploaded_file($tmpName, $chunkFile)) {
return ["error" => "Failed to move uploaded chunk $chunkNumber"];
}
// Check if all chunks are present.
$allChunksPresent = true;
// all chunks present?
for ($i = 1; $i <= $totalChunks; $i++) {
if (!file_exists($tempDir . $i)) {
$allChunksPresent = false;
break;
return ["status" => "chunk uploaded"];
}
}
if (!$allChunksPresent) {
return ["status" => "chunk uploaded"];
}
// Merge chunks.
// merge
$targetPath = $baseUploadDir . $resumableFilename;
if (!$out = fopen($targetPath, "wb")) {
return ["error" => "Failed to open target file for writing"];
}
for ($i = 1; $i <= $totalChunks; $i++) {
$chunkPath = $tempDir . $i;
if (!file_exists($chunkPath)) {
fclose($out);
return ["error" => "Chunk $i missing during merge"];
}
if (!$in = fopen($chunkPath, "rb")) {
fclose($out);
return ["error" => "Failed to open chunk $i"];
}
while ($buff = fread($in, 4096)) {
fwrite($out, $buff);
}
if (!file_exists($chunkPath)) { fclose($out); return ["error" => "Chunk $i missing during merge"]; }
if (!$in = fopen($chunkPath, "rb")) { fclose($out); return ["error" => "Failed to open chunk $i"]; }
while ($buff = fread($in, 4096)) { fwrite($out, $buff); }
fclose($in);
}
fclose($out);
// Update metadata.
$relativeFolder = $folder;
$metadataKey = ($relativeFolder === '' || strtolower($relativeFolder) === 'root') ? "root" : $relativeFolder;
// metadata
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
$metadataCollection = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_array($metadataCollection)) {
$metadataCollection = [];
$metadataFile = META_DIR . $metadataFileName;
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
$collection = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_array($collection)) $collection = [];
if (!isset($collection[$resumableFilename])) {
$collection[$resumableFilename] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
file_put_contents($metadataFile, json_encode($collection, JSON_PRETTY_PRINT));
}
if (!isset($metadataCollection[$resumableFilename])) {
$metadataCollection[$resumableFilename] = [
"uploaded" => $uploadedDate,
"uploader" => $uploader
];
file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT));
}
// Cleanup temporary directory.
$rrmdir = function($dir) use (&$rrmdir) {
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);
// cleanup temp
self::rrmdir($tempDir);
return ["success" => "File uploaded successfully"];
} else {
// Handle full upload (non-chunked).
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name"];
}
$baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
return ["error" => "Failed to create upload directory"];
}
$safeFileNamePattern = REGEX_FILE_NAME;
$metadataCollection = [];
$metadataChanged = [];
foreach ($files["file"]["name"] as $index => $fileName) {
$safeFileName = trim(urldecode(basename($fileName)));
if (!preg_match($safeFileNamePattern, $safeFileName)) {
return ["error" => "Invalid file name: " . $fileName];
}
$relativePath = '';
if (isset($post['relativePath'])) {
$relativePath = is_array($post['relativePath']) ? $post['relativePath'][$index] ?? '' : $post['relativePath'];
}
$uploadDir = $baseUploadDir;
if (!empty($relativePath)) {
$subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') {
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
}
$safeFileName = basename($relativePath);
}
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0775, true)) {
return ["error" => "Failed to create subfolder"];
}
$targetPath = $uploadDir . $safeFileName;
if (move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
$folderPath = $folder;
$metadataKey = ($folderPath === '' || strtolower($folderPath) === 'root') ? "root" : $folderPath;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
if (!isset($metadataCollection[$metadataKey])) {
$metadataCollection[$metadataKey] = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_array($metadataCollection[$metadataKey])) {
$metadataCollection[$metadataKey] = [];
}
$metadataChanged[$metadataKey] = false;
}
if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
$metadataCollection[$metadataKey][$safeFileName] = [
"uploaded" => $uploadedDate,
"uploader" => $uploader
];
$metadataChanged[$metadataKey] = true;
}
} else {
return ["error" => "Error uploading file"];
}
}
foreach ($metadataCollection as $folderKey => $data) {
if ($metadataChanged[$folderKey]) {
$metadataFileName = str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
file_put_contents($metadataFile, json_encode($data, JSON_PRETTY_PRINT));
}
}
return ["success" => "Files uploaded successfully"];
}
// --- NON-CHUNKED ---
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
$baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
return ["error" => "Failed to create upload directory"];
}
$safeFileNamePattern = REGEX_FILE_NAME;
$metadataCollection = [];
$metadataChanged = [];
foreach ($files["file"]["name"] as $index => $fileName) {
if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
return ["error" => "Error uploading file"];
}
$safeFileName = trim(urldecode(basename($fileName)));
if (!preg_match($safeFileNamePattern, $safeFileName)) {
return ["error" => "Invalid file name: " . $fileName];
}
$relativePath = '';
if (isset($post['relativePath'])) {
$relativePath = is_array($post['relativePath']) ? ($post['relativePath'][$index] ?? '') : $post['relativePath'];
}
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
if (!empty($relativePath)) {
$subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') {
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
}
$safeFileName = basename($relativePath);
}
if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) {
return ["error" => "Failed to create subfolder: " . $uploadDir];
}
$targetPath = $uploadDir . $safeFileName;
if (!move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
return ["error" => "Error uploading file"];
}
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
if (!isset($metadataCollection[$metadataKey])) {
$metadataCollection[$metadataKey] = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_array($metadataCollection[$metadataKey])) $metadataCollection[$metadataKey] = [];
$metadataChanged[$metadataKey] = false;
}
if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
$metadataCollection[$metadataKey][$safeFileName] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
$metadataChanged[$metadataKey] = true;
}
}
foreach ($metadataCollection as $folderKey => $data) {
if (!empty($metadataChanged[$folderKey])) {
$metadataFileName = str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
file_put_contents($metadataFile, json_encode($data, JSON_PRETTY_PRINT));
}
}
return ["success" => "Files uploaded successfully"];
}
/**
* Recursively removes a directory and its contents.

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