Compare commits

...

30 Commits

Author SHA1 Message Date
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
Ryan
d29900d6ba security(acl): enforce folder-scope & own-only; fix file list “Select All”; harden ops 2025-10-20 02:28:03 -04:00
Ryan
5ffc068041 docs(README): remove public demo badge/credentials 2025-10-20 01:18:59 -04:00
Ryan
1935cb2442 feat(dnd): default cards to sidebar on medium screens when no saved layout 2025-10-20 01:12:50 -04:00
44 changed files with 5074 additions and 2089 deletions

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

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

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

@@ -0,0 +1,107 @@
---
name: Release on version.js update
on:
push:
branches:
- master
paths:
- public/js/version.js
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: 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
git fetch --tags --quiet
if git rev-parse -q --verify "refs/tags/${{ steps.ver.outputs.version }}" >/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${{ steps.ver.outputs.version }} already exists. Skipping release."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Prepare release notes from CHANGELOG.md (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 > RELEASE_BODY.md || true
# Trim trailing blank lines
sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' RELEASE_BODY.md || true
if [[ -s RELEASE_BODY.md ]]; then
NOTES_PATH="RELEASE_BODY.md"
fi
fi
echo "path=$NOTES_PATH" >> "$GITHUB_OUTPUT"
- name: (optional) Build archive to attach
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
set -euo pipefail
zip -r "FileRise-${{ steps.ver.outputs.version }}.zip" public/ README.md LICENSE >/dev/null || true
# Path A: we have extracted notes -> use body_path
- name: Create GitHub Release (with CHANGELOG snippet)
if: steps.tagcheck.outputs.exists == 'false' && steps.notes.outputs.path != ''
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.version }}
target_commitish: ${{ github.sha }}
name: ${{ steps.ver.outputs.version }}
body_path: ${{ steps.notes.outputs.path }}
generate_release_notes: false
files: |
FileRise-${{ steps.ver.outputs.version }}.zip
# Path B: no notes -> let GitHub auto-generate from commits
- name: Create GitHub Release (auto notes)
if: steps.tagcheck.outputs.exists == 'false' && steps.notes.outputs.path == ''
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.version }}
target_commitish: ${{ github.sha }}
name: ${{ steps.ver.outputs.version }}
generate_release_notes: true
files: |
FileRise-${{ steps.ver.outputs.version }}.zip

View File

@@ -1,5 +1,5 @@
---
name: Sync Changelog to Docker Repo
name: Bump version and sync Changelog to Docker Repo
on:
push:
@@ -10,35 +10,69 @@ permissions:
contents: write
jobs:
sync:
bump_and_sync:
runs-on: ubuntu-latest
steps:
- name: Checkout FileRise
uses: actions/checkout@v4
with:
path: file-rise
- uses: actions/checkout@v4
- name: Extract version from commit message
id: ver
run: |
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
- name: Update public/js/version.js
if: steps.ver.outputs.version != ''
run: |
cat > public/js/version.js <<'EOF'
// generated by CI
window.APP_VERSION = '${{ steps.ver.outputs.version }}';
EOF
- name: Commit version.js (if changed)
if: steps.ver.outputs.version != ''
run: |
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: set APP_VERSION to ${{ steps.ver.outputs.version }}"
git push
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
- name: Copy CHANGELOG.md
- name: Copy CHANGELOG.md and write VERSION
if: steps.ver.outputs.version != ''
run: |
cp file-rise/CHANGELOG.md docker-repo/CHANGELOG.md
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
run: |
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 and VERSION (${{ steps.ver.outputs.version }}) from FileRise"
git push origin main
fi

View File

@@ -1,5 +1,264 @@
# Changelog
## 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
### fileListView.js (v1.5.3)
- Restore master “Select All” checkbox behavior and row highlighting.
- Keep selection working with own-only filtered lists.
- Build preview/thumb URLs via secure API endpoints; avoid direct /uploads.
- Minor UI polish: slider wiring and pagination focus handling.
### FileController.php (v1.5.3)
- Add enforceFolderScope($folder, $user, $perms, $need) and apply across actions.
- Copy/Move: require read on source, write on destination; apply scope on both.
- When user only has read_own, enforce per-file ownership (uploader==user).
- Extract ZIP: require write + scope; consistent 403 messages.
- Save/Rename/Delete/Create: tighten ACL checks; block dangerous extensions; consistent CSRF/Auth handling and error codes.
- Download/ZIP: honor read vs read_own; own-only gates by uploader; safer headers.
### FolderController.php (v1.5.3)
- Align with ACL: enforce folder-scope for non-admins; require owner or bypass for destructive ops.
- Create/Rename/Delete: gate by write on parent/target + ownership when needed.
- Share folder link: require share capability; forbid root sharing for non-admins; validate expiry; optional password.
- Folder listing: return only folders user can fully view or has read_own.
- Shared downloads/uploads: stricter validation, headers, and error handling.
This commits a consistent, least-privilege ACL model (owners/read/write/share/read_own), fixes bulk-select in the UI, and closes scope/ownership gaps across file & folder actions.
feat(dnd): default cards to sidebar on medium screens when no saved layout
- Adds one-time responsive default in loadSidebarOrder() (uses layoutDefaultApplied_v1)
- 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)
fix(admin): modal bugs; chore(api): update ReDoc SRI; docs(openapi): add annotations + spec

120
README.md
View File

@@ -4,20 +4,28 @@
[![Docker pulls](https://img.shields.io/docker/pulls/error311/filerise-docker)](https://hub.docker.com/r/error311/filerise-docker)
[![Docker CI](https://img.shields.io/github/actions/workflow/status/error311/filerise-docker/main.yml?branch=main&label=Docker%20CI)](https://github.com/error311/filerise-docker/actions/workflows/main.yml)
[![CI](https://img.shields.io/github/actions/workflow/status/error311/FileRise/ci.yml?branch=master&label=CI)](https://github.com/error311/FileRise/actions/workflows/ci.yml)
[![Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://demo.filerise.net) **demo / demo**
[![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
@@ -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**
@@ -153,6 +203,8 @@ services:
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 +269,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 +285,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 +375,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).

View File

@@ -40,6 +40,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

@@ -50,6 +50,12 @@ RewriteEngine On
<FilesMatch "\.(js|css)$">
Header set Cache-Control "public, max-age=3600, must-revalidate"
</FilesMatch>
# version.js should always revalidate (it changes on releases)
<FilesMatch "^js/version\.js$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
</IfModule>
# -----------------------------

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

@@ -1046,11 +1046,6 @@ label {
display: none;
}
#createFolderBtn {
margin-top: 0px !important;
height: 40px !important;
font-size: 1rem;
}
.folder-actions {
display: flex;
@@ -1058,6 +1053,7 @@ label {
padding-left: 8px;
align-items: center;
white-space: nowrap;
padding-top: 10px;
}
@media (min-width: 600px) and (max-width: 992px) {
@@ -1066,6 +1062,70 @@ label {
}
}
.folder-actions .btn {
padding: 10px 12px;
font-size: 0.85rem;
line-height: 1.1;
border-radius: 6px;
}
.folder-actions .material-icons {
font-size: 24px;
vertical-align: -2px;
}
.folder-actions .btn + .btn {
margin-left: 6px;
}
.folder-actions .btn {
padding: 10px 12px;
font-size: 0.85rem;
line-height: 1.1;
border-radius: 6px;
transform: scale(1);
transform-origin: center;
transition: transform 120ms ease, box-shadow 120ms ease;
will-change: transform;
}
.folder-actions .material-icons {
font-size: 24px;
vertical-align: -2px;
transition: transform 120ms ease;
}
.folder-actions .btn:hover,
.folder-actions .btn:focus-visible {
transform: scale(1.06);
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
.folder-actions .btn:hover .material-icons,
.folder-actions .btn:focus-visible .material-icons {
transform: scale(1.05);
}
.folder-actions .btn:focus-visible {
outline: 2px solid rgba(33,150,243,0.6);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.folder-actions .btn,
.folder-actions .material-icons {
transition: none;
}
}
#moveFolderBtn {
background-color: #ff9800;
border-color: #ff9800;
color: #fff;
}
.row-selected {
background-color: #f2f2f2 !important;
}
@@ -2036,10 +2096,9 @@ body.dark-mode .admin-panel-content label {
}
#openChangePasswordModalBtn {
width: auto;
padding: 5px 10px;
width: max-content;
padding: 6px 12px;
font-size: 14px;
margin-right: 300px;
}
#changePasswordModal {
@@ -2308,4 +2367,72 @@ body.dark-mode .user-dropdown .user-menu .item:hover {
}
:root { --perm-caret: #444; } /* light */
body.dark-mode { --perm-caret: #ccc; } /* dark */
body.dark-mode { --perm-caret: #ccc; } /* dark */
#zonesToggleFloating,
#sidebarToggleFloating {
transition:
transform 160ms cubic-bezier(.2,.0,.2,1),
box-shadow 160ms cubic-bezier(.2,.0,.2,1),
border-color 160ms cubic-bezier(.2,.0,.2,1),
background-color 160ms cubic-bezier(.2,.0,.2,1);
}
:root { --toggle-icon-color: #333; }
body.dark-mode { --toggle-icon-color: #eee; }
#zonesToggleFloating .material-icons,
#zonesToggleFloating .material-icons-outlined,
#sidebarToggleFloating .material-icons,
#sidebarToggleFloating .material-icons-outlined {
color: var(--toggle-icon-color);
font-size: 22px;
line-height: 1;
display: block;
}
#zonesToggleFloating:hover,
#sidebarToggleFloating:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0,0,0,.14);
border-color: #cfcfcf;
}
#zonesToggleFloating:active,
#sidebarToggleFloating:active {
transform: translateY(0) scale(.96);
box-shadow: 0 3px 8px rgba(0,0,0,.12);
}
#zonesToggleFloating:focus-visible,
#sidebarToggleFloating:focus-visible {
outline: none;
box-shadow:
0 6px 16px rgba(0,0,0,.14),
0 0 0 3px rgba(25,118,210,.25); /* soft brandy ring */
}
#zonesToggleFloating::after,
#sidebarToggleFloating::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle, rgba(0,0,0,.12) 0%, rgba(0,0,0,0) 60%);
transform: scale(0);
opacity: 0;
transition: transform 300ms ease, opacity 450ms ease;
pointer-events: none;
}
#zonesToggleFloating:active::after,
#sidebarToggleFloating:active::after {
transform: scale(1.4);
opacity: 1;
}
#zonesToggleFloating.is-collapsed,
#sidebarToggleFloating.is-collapsed {
background: #fafafa;
border-color: #e2e2e2;
}

View File

@@ -286,9 +286,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">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>
@@ -389,16 +407,14 @@
</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>
<ul
id="createMenu"
class="dropdown-menu"
style="
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
<button id="createBtn" class="btn action-btn" style="display: none;" data-i18n-key="create">
${t('create')} <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%;
@@ -411,27 +427,23 @@
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
z-index: 1000;
min-width: 140px;
"
>
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file" style="padding:8px 12px; cursor:pointer;">
${t('create_file')}
</li>
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder" style="padding:8px 12px; cursor:pointer;">
${t('create_folder')}
</li>
</ul>
</div>
">
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file"
style="padding:8px 12px; cursor:pointer;">
${t('create_file')}
</li>
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder"
style="padding:8px 12px; cursor:pointer;">
${t('create_folder')}
</li>
</ul>
</div>
<!-- Create File Modal -->
<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>
@@ -563,6 +575,7 @@
</div>
</div>
</div>
<script src="js/version.js"></script>
<script type="module" src="js/main.js"></script>
</body>

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -9,29 +9,62 @@ 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/";
// Which mode file to load for a given name/mime
const MODE_URL = {
// core you've likely already loaded:
"xml": "mode/xml/xml.min.js",
"css": "mode/css/css.min.js",
// core/common
"xml": "mode/xml/xml.min.js",
"css": "mode/css/css.min.js",
"javascript": "mode/javascript/javascript.min.js",
// extras you may want on-demand:
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js",
// meta / combos
"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",
// docs / data
"markdown": "mode/markdown/markdown.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"
"sql": "mode/sql/sql.min.js",
// shells
"shell": "mode/shell/shell.min.js",
// languages
"python": "mode/python/python.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"
};
// 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 MODE_SRI = {
"mode/xml/xml.min.js": "sha512-LarNmzVokUmcA7aUDtqZ6oTS+YXmUKzpGdm8DxC46A6AHu+PQiYCUlwEGWidjVYMo/QXZMFMIadZtrkfApYp/g==",
"mode/css/css.min.js": "sha512-oikhYLgIKf0zWtVTOXh101BWoSacgv4UTJHQOHU+iUQ1Dol3Xjz/o9Jh0U33MPoT/d4aQruvjNvcYxvkTQd0nA==",
"mode/javascript/javascript.min.js": "sha512-I6CdJdruzGtvDyvdO4YsiAq+pkWf2efgd1ZUSK2FnM/u2VuRASPC7GowWQrWyjxCZn6CT89s3ddGI+be0Ak9Fg==",
"mode/htmlmixed/htmlmixed.min.js": "sha512-HN6cn6mIWeFJFwRN9yetDAMSh+AK9myHF1X9GlSlKmThaat65342Yw8wL7ITuaJnPioG0SYG09gy0qd5+s777w==",
"mode/php/php.min.js": "sha512-jZGz5n9AVTuQGhKTL0QzOm6bxxIQjaSbins+vD3OIdI7mtnmYE6h/L+UBGIp/SssLggbkxRzp9XkQNA4AyjFBw==",
"mode/markdown/markdown.min.js": "sha512-DmMao0nRIbyDjbaHc8fNd3kxGsZj9PCU6Iu/CeidLQT9Py8nYVA5n0PqXYmvqNdU+lCiTHOM/4E7bM/G8BttJg==",
"mode/python/python.min.js": "sha512-2M0GdbU5OxkGYMhakED69bw0c1pW3Nb0PeF3+9d+SnwN1ryPx3wiDdNqK3gSM7KAU/pEV+2tFJFbMKjKAahOkQ==",
"mode/sql/sql.min.js": "sha512-u8r8NUnG9B9L2dDmsfvs9ohQ0SO/Z7MB8bkdLxV7fE0Q8bOeP7/qft1D4KyE8HhVrpH3ihSrRoDiMbYR1VQBWQ==",
"mode/shell/shell.min.js": "sha512-HoC6JXgjHHevWAYqww37Gfu2c1G7SxAOv42wOakjR8csbTUfTB7OhVzSJ95LL62nII0RCyImp+7nR9zGmJ1wRQ==",
"mode/yaml/yaml.min.js": "sha512-+aXDZ93WyextRiAZpsRuJyiAZ38ztttUyO/H3FZx4gOAOv4/k9C6Um1CvHVtaowHZ2h7kH0d+orWvdBLPVwb4g==",
"mode/properties/properties.min.js": "sha512-P4OaO+QWj1wPRsdkEHlrgkx+a7qp6nUC8rI6dS/0/HPjHtlEmYfiambxowYa/UfqTxyNUnwTyPt5U6l1GO76yw==",
"mode/clike/clike.min.js": "sha512-l8ZIWnQ3XHPRG3MQ8+hT1OffRSTrFwrph1j1oc1Fzc9UKVGef5XN9fdO0vm3nW0PRgQ9LJgck6ciG59m69rvfg=="
};
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
function loadScriptOnce(url) {
return new Promise((resolve, reject) => {
const key = `cm:${url}`;
@@ -39,30 +72,47 @@ function loadScriptOnce(url) {
if (s) {
if (s.dataset.loaded === "1") return resolve();
s.addEventListener("load", () => resolve());
s.addEventListener("error", reject);
s.addEventListener("error", () => reject(new Error(`Load failed: ${url}`)));
return;
}
s = document.createElement("script");
s.src = url;
s.defer = true;
s.async = true;
s.dataset.key = key;
// 🔒 Add SRI if we have it
const relPath = url.replace(/^https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/codemirror\/5\.65\.5\//, "");
const sri = MODE_SRI[relPath];
if (sri) {
s.integrity = sri;
s.crossOrigin = "anonymous";
// (Optional) further tighten referrer behavior:
// s.referrerPolicy = "no-referrer";
}
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
s.addEventListener("error", reject);
s.addEventListener("error", () => 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 (!window.CodeMirror) return;
const name = normalizeModeName(modeOption);
if (!name) return;
// Already registered?
if ((CodeMirror.modes && CodeMirror.modes[name]) || (CodeMirror.mimeModes && CodeMirror.mimeModes[name])) {
return;
}
const isRegistered = () =>
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) ||
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name]);
if (isRegistered()) 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 (!url) return; // unknown -> stay in text/plain
// Dependencies
if (name === "htmlmixed") {
await Promise.all([
ensureModeLoaded("xml"),
@@ -73,6 +123,7 @@ async function ensureModeLoaded(modeOption) {
if (name === "application/x-httpd-php") {
await ensureModeLoaded("htmlmixed");
}
await loadScriptOnce(CM_CDN + url);
}
@@ -81,67 +132,39 @@ function getModeForFile(fileName) {
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,18 +181,15 @@ 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"
@@ -179,9 +199,7 @@ export function editFile(fileName, folder) {
fetch(fileUrl, { method: "HEAD" })
.then(response => {
const lenHeader =
response.headers.get("content-length") ??
response.headers.get("Content-Length");
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
@@ -192,104 +210,143 @@ export function editFile(fileName, folder) {
})
.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" 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);
// Helper to check whether a mode is currently registered
const modeName = typeof desiredMode === "string" ? desiredMode : (desiredMode && desiredMode.name);
const isModeRegistered = () =>
(window.CodeMirror?.modes && window.CodeMirror.modes[modeName]) ||
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[modeName]);
// Start mode loading (dont block closing)
const modePromise = ensureModeLoaded(desiredMode);
// 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 initialMode = (forcePlainText || !isModeRegistered()) ? "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 && isModeRegistered()) {
editor.setOption("mode", desiredMode);
}
}).catch(() => {
// If the mode truly fails to load, we just stay in plain text
});
});
})
.catch(error => {
@@ -298,7 +355,6 @@ export function editFile(fileName, folder) {
});
}
export function saveFile(fileName, folder) {
const editor = window.currentEditor;
if (!editor) {

View File

@@ -65,6 +65,59 @@ import {
return `/api/file/download.php?${q.toString()}`;
}
// Wire "select all" header checkbox for the current table render
function wireSelectAll(fileListContent) {
// Be flexible about how the header checkbox is identified
const selectAll = fileListContent.querySelector(
'thead input[type="checkbox"].select-all, ' +
'thead .select-all input[type="checkbox"], ' +
'thead input#selectAll, ' +
'thead input#selectAllCheckbox, ' +
'thead input[data-select-all]'
);
if (!selectAll) return;
const getRowCbs = () =>
Array.from(fileListContent.querySelectorAll('tbody .file-checkbox'))
.filter(cb => !cb.disabled);
// Toggle all rows when the header checkbox changes
selectAll.addEventListener('change', () => {
const checked = selectAll.checked;
getRowCbs().forEach(cb => {
cb.checked = checked;
updateRowHighlight(cb);
});
updateFileActionButtons();
// No indeterminate state when explicitly toggled
selectAll.indeterminate = false;
});
// Keep header checkbox state in sync with row selections
const syncHeader = () => {
const cbs = getRowCbs();
const total = cbs.length;
const checked = cbs.filter(cb => cb.checked).length;
if (!total) {
selectAll.checked = false;
selectAll.indeterminate = false;
return;
}
selectAll.checked = checked === total;
selectAll.indeterminate = checked > 0 && checked < total;
};
// Listen for any row checkbox changes to refresh header state
fileListContent.addEventListener('change', (e) => {
if (e.target && e.target.classList.contains('file-checkbox')) {
syncHeader();
}
});
// Initial sync on mount
syncHeader();
}
/* -----------------------------
Helper: robust JSON handling
----------------------------- */
@@ -607,6 +660,8 @@ import {
const bottomControlsHTML = buildBottomControls(itemsPerPageSetting);
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
wireSelectAll(fileListContent);
// PATCH each row's preview/thumb to use the secure API URLs
if (totalFiles > 0) {
@@ -985,6 +1040,7 @@ import {
// render
fileListContent.innerHTML = galleryHTML;
// pagination buttons for gallery
const prevBtn = document.getElementById("prevPageBtn");

View File

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

@@ -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.",
@@ -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';

View File

@@ -105,12 +105,14 @@ export function initializeApp() {
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
window.currentFolder = "root";
//window.currentFolder = "root";
const last = localStorage.getItem('lastOpenedFolder');
window.currentFolder = last ? last : "root";
const stored = localStorage.getItem('showFoldersInList');
window.showFoldersInList = stored === null ? true : stored === 'true';
loadAdminConfigFunc();
initTagSearch();
loadFileList(window.currentFolder);
//loadFileList(window.currentFolder);
const fileListArea = document.getElementById('fileListContainer');
const uploadArea = document.getElementById('uploadDropArea');

View File

@@ -161,91 +161,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");
@@ -406,20 +406,27 @@ 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
chunkSize: 1.5 * 1024 * 1024,
simultaneousUploads: 3,
forceChunkSize: true,
testChunks: false,
throttleProgressCallbacks: 1,
withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken },
query: {
query: () => ({
folder: window.currentFolder || "root",
upload_token: window.csrfToken // still as a fallback
}
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;
// if you're not using a function for query, do:
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.
@@ -432,6 +439,7 @@ function initResumableUpload() {
}
resumableInstance.on("fileAdded", function (file) {
// Initialize custom paused flag
file.paused = false;
file.uploadIndex = file.uniqueIdentifier;
@@ -461,16 +469,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 +500,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 +508,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 +517,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 +540,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) {
@@ -637,7 +646,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 +659,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 +671,7 @@ function submitFiles(allFiles) {
if (li.removeBtn) li.removeBtn.style.display = "none";
}
uploadResults[file.uploadIndex] = true;
} else {
// real failure
if (li) {
@@ -681,12 +691,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 +714,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 +743,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 +788,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 +802,7 @@ function submitFiles(allFiles) {
})
.finally(() => {
loadFolderTree(window.currentFolder);
});
}
}

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -96,6 +96,11 @@ class FolderController
return false;
}
private static function isFolderOnly(array $perms): bool
{
return !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
}
private static function requireNotReadOnly(): void
{
$perms = self::getPerms();
@@ -126,25 +131,84 @@ class FolderController
return round($bytes / 1073741824, 2) . " GB";
}
/** Enforce "user folder only" scope for non-admins. Returns error string or null if allowed. */
private static function enforceFolderScope(string $folder, string $username, array $perms): ?string
/** Return true if user is explicit owner of the folder or any of its ancestors (admins also true). */
private static function ownsFolderOrAncestor(string $folder, string $username, array $perms): bool
{
if (self::isAdmin($perms)) return null;
$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
if (!$folderOnly) return null;
$folder = trim($folder);
if ($folder === '' || strcasecmp($folder, 'root') === 0) {
return "Forbidden: non-admins may not operate on the root folder.";
if (self::isAdmin($perms)) return true;
$folder = ACL::normalizeFolder($folder);
$f = $folder;
while ($f !== '' && strtolower($f) !== 'root') {
if (ACL::isOwner($username, $perms, $f)) return true;
$pos = strrpos($f, '/');
$f = ($pos === false) ? '' : substr($f, 0, $pos);
}
if ($folder === $username || strpos($folder, $username . '/') === 0) {
return null;
}
return "Forbidden: folder scope violation.";
return false;
}
/**
* Enforce per-folder scope for folder-only accounts.
* $need: 'read' | 'write' | 'manage' | 'share' | 'read_own' (default 'read')
* Returns null if allowed, or an error string if forbidden.
*/
// 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;
// If this account isn't folder-scoped, don't gate here
if (!self::isFolderOnly($perms)) return null;
$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);
}
// 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
{
@@ -152,18 +216,10 @@ class FolderController
return (bool)($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
}
/** Returns true if caller can share. */
private static function canShare(array $perms): bool
/** ACL-aware folder owner check (explicit). */
private static function isFolderOwner(string $folder, string $username, array $perms): bool
{
if (self::isAdmin($perms)) return true;
return (bool)($perms['canShare'] ?? (defined('DEFAULT_CAN_SHARE') ? DEFAULT_CAN_SHARE : false));
}
/** Check folder ownership via mapping; returns true if $username is the explicit owner. */
private static function isFolderOwner(string $folder, string $username): bool
{
$owner = FolderModel::getOwnerFor($folder);
return is_string($owner) && strcasecmp($owner, $username) === 0;
return ACL::isOwner($username, $perms, $folder);
}
/* -------------------- API: Create Folder -------------------- */
@@ -171,41 +227,54 @@ class FolderController
{
header('Content-Type: application/json');
self::requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit; }
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']) : '';
$folderName = trim((string)$input['folderName']);
$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;
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
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.']); return;
}
$parent = ($parentIn === '' ? 'root' : $parentIn);
$username = $_SESSION['username'] ?? '';
$perms = self::getPerms();
// 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: manager/owner required on parent.']);
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;
}
$result = FolderModel::createFolder($folderName, $parent, $username);
if (empty($result['success'])) {
http_response_code(400);
echo json_encode($result);
return;
}
echo json_encode($result);
} 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.']);
}
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;
}
// Normalize parent to an ACL key
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
$username = $_SESSION['username'] ?? '';
$perms = self::getPerms();
// ACL: must be able to WRITE into the parent folder (admins pass)
if (!self::isAdmin($perms) && !ACL::canWrite($username, $perms, $parent)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: no write access to parent folder.']);
exit;
}
// Let the model do the filesystem work AND seed ACL owner
$result = FolderModel::createFolder($folderName, $parent, $username);
echo json_encode($result);
exit;
}
/* -------------------- API: Delete Folder -------------------- */
@@ -220,15 +289,26 @@ class FolderController
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['folder'])) { http_response_code(400); echo json_encode(["error" => "Folder name not provided."]); exit; }
$folder = trim($input['folder']);
$folder = trim((string)$input['folder']);
if (strcasecmp($folder, 'root') === 0) { http_response_code(400); echo json_encode(["error" => "Cannot delete root folder."]); exit; }
if (!preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; }
$username = $_SESSION['username'] ?? '';
$perms = self::getPerms();
if ($msg = self::enforceFolderScope($folder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => $msg]); exit; }
if (!self::canBypassOwnership($perms) && !self::isFolderOwner($folder, $username)) {
// Folder-scope: need manage (owner) OR explicit manage grant
if ($msg = self::enforceFolderScope($folder, $username, $perms, 'manage')) {
http_response_code(403); echo json_encode(["error" => $msg]); exit;
}
// Require either manage permission or ancestor ownership (strong gate)
$canManage = ACL::canManage($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms);
if (!$canManage) {
http_response_code(403); echo json_encode(["error" => "Forbidden: you lack manage rights for this folder."]); exit;
}
// If not bypassing ownership, require ownership (direct or ancestor) as an extra safeguard
if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) {
http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the folder owner."]); exit;
}
@@ -251,8 +331,8 @@ class FolderController
http_response_code(400); echo json_encode(['error' => 'Required folder names not provided.']); exit;
}
$oldFolder = trim($input['oldFolder']);
$newFolder = trim($input['newFolder']);
$oldFolder = trim((string)$input['oldFolder']);
$newFolder = trim((string)$input['newFolder']);
if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) {
http_response_code(400); echo json_encode(['error' => 'Invalid folder name(s).']); exit;
@@ -261,10 +341,23 @@ class FolderController
$username = $_SESSION['username'] ?? '';
$perms = self::getPerms();
if ($msg = self::enforceFolderScope($oldFolder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => $msg]); exit; }
if ($msg = self::enforceFolderScope($newFolder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => "New path not allowed: " . $msg]); exit; }
// Must be allowed to manage the old folder
if ($msg = self::enforceFolderScope($oldFolder, $username, $perms, 'manage')) {
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, 'manage')) {
http_response_code(403); echo json_encode(["error" => "New path not allowed: " . $msg]); exit;
}
if (!self::canBypassOwnership($perms) && !self::isFolderOwner($oldFolder, $username)) {
// 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;
}
// If not bypassing ownership, require ownership (direct or ancestor) on the old folder
if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($oldFolder, $username, $perms)) {
http_response_code(403); echo json_encode(['error' => 'Forbidden: you are not the folder owner.']); exit;
}
@@ -275,66 +368,64 @@ class FolderController
/* -------------------- API: Get Folder List -------------------- */
public function getFolderList(): void
{
header('Content-Type: application/json');
self::requireAuth();
{
header('Content-Type: application/json');
self::requireAuth();
// Optional "folder" filter (supports nested like "team/reports")
$parent = $_GET['folder'] ?? null;
if ($parent !== null && $parent !== '' && strcasecmp($parent, 'root') !== 0) {
$parts = array_filter(explode('/', trim($parent, "/\\ ")), fn($p) => $p !== '');
if (empty($parts)) {
http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
// Optional "folder" filter (supports nested like "team/reports")
$parent = $_GET['folder'] ?? null;
if ($parent !== null && $parent !== '' && strcasecmp($parent, 'root') !== 0) {
$parts = array_filter(explode('/', trim($parent, "/\\ ")), fn($p) => $p !== '');
if (empty($parts)) {
http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
}
$parent = implode('/', $parts);
}
$parent = implode('/', $parts);
}
$username = $_SESSION['username'] ?? '';
$perms = loadUserPermissions($username) ?: [];
$isAdmin = self::isAdmin($perms);
$username = $_SESSION['username'] ?? '';
$perms = self::getPerms();
$isAdmin = self::isAdmin($perms);
// 1) full list from model
$all = FolderModel::getFolderList(); // each row: ["folder","fileCount","metadataFile"]
if (!is_array($all)) {
echo json_encode([]);
// 1) Full list from model
$all = FolderModel::getFolderList(); // each row: ["folder","fileCount","metadataFile"]
if (!is_array($all)) { echo json_encode([]); exit; }
// 2) Filter by view rights
if (!$isAdmin) {
$all = array_values(array_filter($all, function ($row) use ($username, $perms) {
$f = $row['folder'] ?? '';
if ($f === '') return false;
// Full view if canRead OR owns ancestor; otherwise allow if read_own granted
$fullView = ACL::canRead($username, $perms, $f) || FolderController::ownsFolderOrAncestor($f, $username, $perms);
$ownOnly = ACL::hasGrant($username, $f, 'read_own');
return $fullView || $ownOnly;
}));
}
// 3) Optional parent filter (applies to both admin and non-admin)
if ($parent && strcasecmp($parent, 'root') !== 0) {
$pref = $parent . '/';
$all = array_values(array_filter($all, function ($row) use ($parent, $pref) {
$f = $row['folder'] ?? '';
return ($f === $parent) || (strpos($f, $pref) === 0);
}));
}
echo json_encode($all);
exit;
}
// 2) Admin sees all; others: include folder if user has full view OR own-only view
if (!$isAdmin) {
$all = array_values(array_filter($all, function ($row) use ($username, $perms) {
$f = $row['folder'] ?? '';
if ($f === '') return false;
$fullView = ACL::canRead($username, $perms, $f); // owners|write|read
$ownOnly = ACL::hasGrant($username, $f, 'read_own'); // view-own
return $fullView || $ownOnly;
}));
}
// 3) Optional parent filter (applies to both admin and non-admin)
if ($parent && strcasecmp($parent, 'root') !== 0) {
$pref = $parent . '/';
$all = array_values(array_filter($all, function ($row) use ($parent, $pref) {
$f = $row['folder'] ?? '';
return ($f === $parent) || (strpos($f, $pref) === 0);
}));
}
echo json_encode($all);
exit;
}
/* -------------------- Public Shared Folder HTML -------------------- */
public function shareFolder(): void
{
@@ -451,10 +542,10 @@ for ($i = $startPage; $i <= $endPage; $i++): ?>
$in = json_decode(file_get_contents("php://input"), true);
if (!$in || !isset($in['folder'])) { http_response_code(400); echo json_encode(["error" => "Invalid input."]); exit; }
$folder = trim($in['folder']);
$folder = trim((string)$in['folder']);
$value = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60;
$unit = $in['expirationUnit'] ?? 'minutes';
$password = $in['password'] ?? '';
$password = (string)($in['password'] ?? '');
$allowUpload = intval($in['allowUpload'] ?? 0);
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; }
@@ -463,14 +554,18 @@ for ($i = $startPage; $i <= $endPage; $i++): ?>
$perms = self::getPerms();
$isAdmin = self::isAdmin($perms);
if (!self::canShare($perms)) { http_response_code(403); echo json_encode(["error" => "Sharing is not permitted for your account."]); exit; }
if (!$isAdmin) {
if (strcasecmp($folder, 'root') === 0) { http_response_code(403); echo json_encode(["error" => "Only admins may share the root folder."]); exit; }
if ($msg = self::enforceFolderScope($folder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => $msg]); exit; }
// Must have share on this folder OR be ancestor owner
if (!(ACL::canShare($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms))) {
http_response_code(403); echo json_encode(["error" => "Sharing is not permitted for your account."]); exit;
}
if (!self::canBypassOwnership($perms) && !self::isFolderOwner($folder, $username)) {
// Folder-scope: need share capability within scope
if ($msg = self::enforceFolderScope($folder, $username, $perms, 'share')) {
http_response_code(403); echo json_encode(["error" => $msg]); exit;
}
// Ownership requirement unless bypassed (allow ancestor owners)
if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) {
http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the owner of this folder."]); exit;
}
@@ -600,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

@@ -57,7 +57,7 @@ class UploadController {
$targetFolder = ACL::normalizeFolder($folderParam);
// Admins bypass folder canWrite checks
if (!$isAdmin && !ACL::canWrite($username, $userPerms, $targetFolder)) {
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
return;

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

@@ -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,6 +4,19 @@
require_once PROJECT_ROOT . '/config/config.php';
class UploadModel {
private static function sanitizeFolder(string $folder): string {
$folder = trim($folder);
if ($folder === '' || strtolower($folder) === 'root') return '';
// no traversal
if (strpos($folder, '..') !== false) return '';
// only safe chars + forward slashes
if (!preg_match('/^[A-Za-z0-9_\-\/]+$/', $folder)) return '';
// normalize: strip leading slashes
return ltrim($folder, '/');
}
/**
* Handles file uploads supports both chunked uploads and full (non-chunked) uploads.
*
@@ -38,15 +51,19 @@ class UploadModel {
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"];
}
$folderRaw = $post['folder'] ?? 'root';
$folderSan = self::sanitizeFolder((string)$folderRaw);
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"];
}
@@ -56,12 +73,14 @@ class UploadModel {
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"];
}
@@ -100,8 +119,7 @@ class UploadModel {
fclose($out);
// Update metadata.
$relativeFolder = $folder;
$metadataKey = ($relativeFolder === '' || strtolower($relativeFolder) === 'root') ? "root" : $relativeFolder;
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
$uploadedDate = date(DATE_TIME_FORMAT);
@@ -134,16 +152,16 @@ class UploadModel {
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"];
// Handle full upload (non-chunked)
$folderRaw = $post['folder'] ?? 'root';
$folderSan = self::sanitizeFolder((string)$folderRaw);
}
$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"];
}
@@ -153,6 +171,10 @@ class UploadModel {
$metadataChanged = [];
foreach ($files["file"]["name"] as $index => $fileName) {
// Basic PHP upload error check per file
if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
return ["error" => "Error uploading file"];
}
$safeFileName = trim(urldecode(basename($fileName)));
if (!preg_match($safeFileNamePattern, $safeFileName)) {
return ["error" => "Invalid file name: " . $fileName];
@@ -161,21 +183,22 @@ class UploadModel {
if (isset($post['relativePath'])) {
$relativePath = is_array($post['relativePath']) ? $post['relativePath'][$index] ?? '' : $post['relativePath'];
}
$uploadDir = $baseUploadDir;
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
if (!empty($relativePath)) {
$subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') {
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
// IMPORTANT: build the subfolder under the *current* base folder
$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"];
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)) {
$folderPath = $folder;
$metadataKey = ($folderPath === '' || strtolower($folderPath) === 'root') ? "root" : $folderPath;
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
if (!isset($metadataCollection[$metadataKey])) {
@@ -208,7 +231,7 @@ class UploadModel {
}
return ["success" => "Files uploaded successfully"];
}
}
/**
* Recursively removes a directory and its contents.

View File

@@ -1,6 +1,8 @@
<?php
namespace FileRise\WebDAV;
//src/webdav/FileRiseDirectory.php
require_once __DIR__ . '/../../config/config.php'; // constants + loadUserPermissions()
require_once __DIR__ . '/../../vendor/autoload.php'; // SabreDAV
require_once __DIR__ . '/../../src/lib/ACL.php';
@@ -166,9 +168,9 @@ class FileRiseDirectory implements ICollection, INode {
public function createDirectory($name): INode {
$parentKey = $this->folderKeyForPath($this->path);
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $parentKey)) {
throw new Forbidden('No permission to create subfolders here');
}
if (!$this->isAdmin && !\ACL::canManage($this->user, $this->perms, $parentKey)) {
throw new Forbidden('No permission to create subfolders here');
}
$full = $this->path . DIRECTORY_SEPARATOR . $name;
if (!is_dir($full)) {

View File

@@ -38,8 +38,9 @@ class FileRiseFile implements IFile, INode {
public function delete(): void {
[$folderKey, $fileName] = $this->split();
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
throw new Forbidden('No write access to delete this file');
if (!$this->isAdmin && !\ACL::canDelete($this->user, $this->perms, $folderKey)) {
throw new Forbidden('No delete permission in this folder');
}
if (!$this->canTouchOwnership($folderKey, $fileName)) {
throw new Forbidden('You do not own this file');
@@ -67,34 +68,40 @@ class FileRiseFile implements IFile, INode {
public function put($data): ?string {
[$folderKey, $fileName] = $this->split();
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
throw new Forbidden('No write access to this folder');
}
if (!empty($this->perms['disableUpload']) && !$this->isAdmin) {
throw new Forbidden('Uploads are disabled for your account');
}
// If overwriting existing file, enforce ownership for non-admin unless bypassOwnership
$exists = is_file($this->path);
$bypass = !empty($this->perms['bypassOwnership']);
if ($exists && !$this->isAdmin && !$bypass && !$this->isOwner($folderKey, $fileName)) {
if (!$this->isAdmin) {
// uploads disabled blocks both create & overwrite
if (!empty($this->perms['disableUpload'])) {
throw new Forbidden('Uploads are disabled for your account');
}
// granular gates
if ($exists) {
if (!\ACL::canEdit($this->user, $this->perms, $folderKey)) {
throw new Forbidden('No edit permission in this folder');
}
} else {
if (!\ACL::canUpload($this->user, $this->perms, $folderKey)) {
throw new Forbidden('No upload permission in this folder');
}
}
}
// Ownership on overwrite (unless admin/bypass)
$bypass = !empty($this->perms['bypassOwnership']) || $this->isAdmin;
if ($exists && !$bypass && !$this->isOwner($folderKey, $fileName)) {
throw new Forbidden('You do not own the target file');
}
// Write data
// write + metadata (unchanged)
file_put_contents(
$this->path,
is_resource($data) ? stream_get_contents($data) : (string)$data
);
// Update metadata (uploader on first write; modified every write)
$this->updateMetadata($folderKey, $fileName);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
return null; // no ETag
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
return null;
}
public function getSize(): int {