Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e37738e3f | ||
|
|
2ba33f40f8 | ||
|
|
badcf5c02b | ||
|
|
89976f444f | ||
|
|
9c53c37f38 | ||
|
|
a400163dfb | ||
|
|
ebe5939bf5 | ||
|
|
83757c7470 | ||
|
|
8e363ea758 | ||
|
|
2739925f0b | ||
|
|
b5610cf156 | ||
|
|
ae932a9aa9 | ||
|
|
a106d47f77 | ||
|
|
41d464a4b3 | ||
|
|
9e69f19e23 | ||
|
|
1df7bc3f87 | ||
|
|
e5f9831d73 | ||
|
|
553bc84404 | ||
|
|
88a8857a6f | ||
|
|
edefaaca36 | ||
|
|
ef0a8da696 |
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
github: [error311]
|
||||||
|
ko_fi: error311
|
||||||
107
.github/workflows/release-on-version.yml
vendored
Normal 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
|
||||||
56
.github/workflows/sync-changelog.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
name: Sync Changelog to Docker Repo
|
name: Bump version and sync Changelog to Docker Repo
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -10,35 +10,69 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync:
|
bump_and_sync:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout FileRise
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
- name: Extract version from commit message
|
||||||
path: file-rise
|
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
|
- name: Checkout filerise-docker
|
||||||
|
if: steps.ver.outputs.version != ''
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: error311/filerise-docker
|
repository: error311/filerise-docker
|
||||||
token: ${{ secrets.PAT_TOKEN }}
|
token: ${{ secrets.PAT_TOKEN }}
|
||||||
path: docker-repo
|
path: docker-repo
|
||||||
|
|
||||||
- name: Copy CHANGELOG.md
|
- name: Copy CHANGELOG.md and write VERSION
|
||||||
|
if: steps.ver.outputs.version != ''
|
||||||
run: |
|
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
|
working-directory: docker-repo
|
||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git add CHANGELOG.md
|
git add CHANGELOG.md VERSION
|
||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
echo "No changes to commit"
|
echo "No changes to commit"
|
||||||
else
|
else
|
||||||
git commit -m "chore: sync CHANGELOG.md from FileRise"
|
git commit -m "chore: sync CHANGELOG.md and VERSION (${{ steps.ver.outputs.version }}) from FileRise"
|
||||||
git push origin main
|
git push origin main
|
||||||
fi
|
fi
|
||||||
|
|||||||
147
CHANGELOG.md
@@ -1,5 +1,152 @@
|
|||||||
# Changelog
|
# 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 doesn’t trigger the logo link; theme-aware styling
|
||||||
|
- live updates via MutationObserver; snapshot card locations on drop and restore
|
||||||
|
on load (prevents sidebar reset); guard first-run defaults with
|
||||||
|
`layoutDefaultApplied_v1`; small/medium layout tweaks & refactors.
|
||||||
|
- CSS: switch toggle icon to CSS variable (`--toggle-icon-color`) with dark-mode
|
||||||
|
override; remove hardcoded `!important`.
|
||||||
|
- API (capabilities.php): remove unused `disableUpload` flag from `canUpload`
|
||||||
|
and flags payload to resolve undefined variable warning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/24/2025 (v1.6.5)
|
||||||
|
|
||||||
|
release(v1.6.5): fix PHP warning and upload-flag check in capabilities.php
|
||||||
|
|
||||||
|
- Fix undefined variable: use $disableUpload consistently
|
||||||
|
- Harden flag read: (bool)($perms['disableUpload'] ?? false)
|
||||||
|
- Prevents warning and ensures Upload capability is computed correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/24/2025 (v1.6.4)
|
||||||
|
|
||||||
|
release(v1.6.4): runtime version injection + CI bump/sync; caching tweaks
|
||||||
|
|
||||||
|
- Add public/js/version.js (default "dev") and load it before main.js.
|
||||||
|
- adminPanel.js: replace hard-coded string with `window.APP_VERSION || "dev"`.
|
||||||
|
- public/.htaccess: add no-cache for js/version.js
|
||||||
|
- GitHub Actions: replace sync job with “Bump version and sync Changelog to Docker Repo”.
|
||||||
|
- Parse commit msg `release(vX.Y.Z)` -> set step output `version`.
|
||||||
|
- Write `public/js/version.js` with `window.APP_VERSION = '<version>'`.
|
||||||
|
- Commit/push version.js if changed.
|
||||||
|
- Mirror CHANGELOG.md to filerise-docker and write a VERSION file with `<version>`.
|
||||||
|
- Guard all steps with `if: steps.ver.outputs.version != ''` to no-op on non-release commits.
|
||||||
|
|
||||||
|
This wires the UI version label to CI, keeps dev builds showing “dev”, and feeds the Docker repo with CHANGELOG + VERSION for builds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/24/2025 (v1.6.3)
|
||||||
|
|
||||||
|
release(v1.6.3): drag/drop card persistence, admin UX fixes, and docs (closes #58)
|
||||||
|
|
||||||
|
Drag & Drop - Upload/Folder Management Cards layout
|
||||||
|
|
||||||
|
- Persist panel locations across refresh; snapshot + restore when collapsing/expanding.
|
||||||
|
- Unified “zones” toggle; header-icon mode no longer loses card state.
|
||||||
|
- Responsive: auto-move sidebar cards to top on small screens; restore on resize.
|
||||||
|
- Better top-zone placeholder/cleanup during drag; tighter header modal sizing.
|
||||||
|
- Safer order saving + deterministic placement for upload/folder cards.
|
||||||
|
|
||||||
|
Admin Panel – Folder Access
|
||||||
|
|
||||||
|
- Fix: newly created folders now appear without a full page refresh (cache-busted `getFolderList`).
|
||||||
|
- Show admin users in the list with full access pre-applied and inputs disabled (read-only).
|
||||||
|
- Skip sending updates for admins when saving grants.
|
||||||
|
- “Folder” column now has its own horizontal scrollbar so long names / “Inherited from …” are never cut off.
|
||||||
|
|
||||||
|
Admin Panel – User Permissions (flags)
|
||||||
|
|
||||||
|
- Show admins (marked as Admin) with all switches disabled; exclude from save payload.
|
||||||
|
- Clarified helper text (account-level vs per-folder).
|
||||||
|
|
||||||
|
UI/Styling
|
||||||
|
|
||||||
|
- Added `.folder-cell` scroller in ACL table; improved dark-mode scrollbar/thumb.
|
||||||
|
|
||||||
|
Docs
|
||||||
|
|
||||||
|
- README edits:
|
||||||
|
- Clarified PUID/PGID mapping and host/NAS ownership requirements for mounted volumes.
|
||||||
|
- Environment variables section added
|
||||||
|
- CHOWN_ON_START additional details
|
||||||
|
- Admin details
|
||||||
|
- Upgrade section added
|
||||||
|
- 💖 Sponsor FileRise section added
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 10/23/2025 (v1.6.2)
|
## Changes 10/23/2025 (v1.6.2)
|
||||||
|
|
||||||
feat(i18n,auth): add Simplified Chinese (zh-CN) and expose in User Panel
|
feat(i18n,auth): add Simplified Chinese (zh-CN) and expose in User Panel
|
||||||
|
|||||||
54
README.md
@@ -7,6 +7,8 @@
|
|||||||
[](https://demo.filerise.net)
|
[](https://demo.filerise.net)
|
||||||
[](https://github.com/error311/FileRise/releases)
|
[](https://github.com/error311/FileRise/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](https://github.com/sponsors/error311)
|
||||||
|
[](https://ko-fi.com/error311)
|
||||||
|
|
||||||
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting)
|
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting)
|
||||||
|
|
||||||
@@ -21,9 +23,9 @@ With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2
|
|||||||
|
|
||||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re 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 mode:**
|
||||||

|

|
||||||
@@ -103,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)
|
### 1) Running with Docker (Recommended)
|
||||||
|
|
||||||
#### Pull the image
|
#### Pull the image
|
||||||
@@ -133,6 +151,8 @@ docker run -d \
|
|||||||
error311/filerise-docker:latest
|
error311/filerise-docker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The app runs as www-data mapped to PUID/PGID. Ensure your mounted uploads/, users/, metadata/ are owned by PUID:PGID (e.g., chown -R 1000:1000 …), or set PUID/PGID to match existing host ownership (e.g., 99:100 on Unraid). On NAS/NFS, apply the ownership change on the host/NAS.
|
||||||
|
|
||||||
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
||||||
|
|
||||||
**Notes**
|
**Notes**
|
||||||
@@ -183,6 +203,8 @@ services:
|
|||||||
Access at `http://localhost:8080` (or your server’s IP).
|
Access at `http://localhost:8080` (or your server’s IP).
|
||||||
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string.
|
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string.
|
||||||
|
|
||||||
|
- “`CHOWN_ON_START=true` attempts to align ownership **inside the container**; if the host/NAS disallows changes, set the correct UID/GID on the host.”
|
||||||
|
|
||||||
**First-time Setup**
|
**First-time Setup**
|
||||||
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. Then use **User Management** to add more users.
|
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. Then use **User Management** to add more users.
|
||||||
|
|
||||||
@@ -247,6 +269,13 @@ Browse to your FileRise URL; you’ll be prompted to create the Admin user on fi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 3) Admins
|
||||||
|
|
||||||
|
> **Admins in ACL UI**
|
||||||
|
> Admin accounts appear in the Folder Access and User Permissions modals as **read-only** with full access implied. This is by design—admins always have full control and are excluded from save payloads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Unraid
|
## Unraid
|
||||||
|
|
||||||
- Install from **Community Apps** → search **FileRise**.
|
- Install from **Community Apps** → search **FileRise**.
|
||||||
@@ -256,6 +285,16 @@ Browse to your FileRise URL; you’ll be prompted to create the Admin user on fi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull error311/filerise-docker:latest
|
||||||
|
docker stop filerise && docker rm filerise
|
||||||
|
# re-run with the same -v and -e flags you used originally
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick-start: Mount via WebDAV
|
## Quick-start: Mount via WebDAV
|
||||||
|
|
||||||
Once FileRise is running, enable WebDAV in the admin panel.
|
Once FileRise is running, enable WebDAV in the admin panel.
|
||||||
@@ -336,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
|
## Community and Support
|
||||||
|
|
||||||
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) – (Announcement and user feedback thread).
|
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) – (Announcement and user feedback thread).
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ RewriteEngine On
|
|||||||
<FilesMatch "\.(js|css)$">
|
<FilesMatch "\.(js|css)$">
|
||||||
Header set Cache-Control "public, max-age=3600, must-revalidate"
|
Header set Cache-Control "public, max-age=3600, must-revalidate"
|
||||||
</FilesMatch>
|
</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>
|
</IfModule>
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ if ($folder !== 'root') {
|
|||||||
$perms = loadPermsFor($username);
|
$perms = loadPermsFor($username);
|
||||||
$isAdmin = ACL::isAdmin($perms);
|
$isAdmin = ACL::isAdmin($perms);
|
||||||
$readOnly = !empty($perms['readOnly']);
|
$readOnly = !empty($perms['readOnly']);
|
||||||
$disableUp = !empty($perms['disableUpload']);
|
|
||||||
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
||||||
|
|
||||||
// --- ACL base abilities ---
|
// --- ACL base abilities ---
|
||||||
@@ -178,7 +177,7 @@ $gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
|
|||||||
|
|
||||||
// --- Apply scope + flags to effective UI actions ---
|
// --- Apply scope + flags to effective UI actions ---
|
||||||
$canView = $canViewBase && $inScope; // keep scope for folder-only
|
$canView = $canViewBase && $inScope; // keep scope for folder-only
|
||||||
$canUpload = $gUploadBase && !$readOnly && !$disableUpload && $inScope;
|
$canUpload = $gUploadBase && !$readOnly && $inScope;
|
||||||
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
|
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
|
||||||
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
|
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
|
||||||
$canDelete = $gDeleteBase && !$readOnly && $inScope;
|
$canDelete = $gDeleteBase && !$readOnly && $inScope;
|
||||||
@@ -186,6 +185,7 @@ $canDelete = $gDeleteBase && !$readOnly && $inScope;
|
|||||||
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
|
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
|
||||||
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
|
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
|
||||||
$canMoveIn = $canReceive;
|
$canMoveIn = $canReceive;
|
||||||
|
$canMoveAlias = $canMoveIn;
|
||||||
$canEdit = $gEditBase && !$readOnly && $inScope;
|
$canEdit = $gEditBase && !$readOnly && $inScope;
|
||||||
$canCopy = $gCopyBase && !$readOnly && $inScope;
|
$canCopy = $gCopyBase && !$readOnly && $inScope;
|
||||||
$canExtract = $gExtractBase && !$readOnly && $inScope;
|
$canExtract = $gExtractBase && !$readOnly && $inScope;
|
||||||
@@ -201,6 +201,12 @@ if ($isRoot) {
|
|||||||
$canRename = false;
|
$canRename = false;
|
||||||
$canDelete = false;
|
$canDelete = false;
|
||||||
$canShareFoldEff = false;
|
$canShareFoldEff = false;
|
||||||
|
$canMoveFolder = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isRoot) {
|
||||||
|
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
|
||||||
|
&& !$readOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
$owner = null;
|
$owner = null;
|
||||||
@@ -213,7 +219,6 @@ echo json_encode([
|
|||||||
'flags' => [
|
'flags' => [
|
||||||
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
||||||
'readOnly' => $readOnly,
|
'readOnly' => $readOnly,
|
||||||
'disableUpload' => $disableUp,
|
|
||||||
],
|
],
|
||||||
'owner' => $owner,
|
'owner' => $owner,
|
||||||
|
|
||||||
@@ -227,6 +232,8 @@ echo json_encode([
|
|||||||
'canRename' => $canRename,
|
'canRename' => $canRename,
|
||||||
'canDelete' => $canDelete,
|
'canDelete' => $canDelete,
|
||||||
'canMoveIn' => $canMoveIn,
|
'canMoveIn' => $canMoveIn,
|
||||||
|
'canMove' => $canMoveAlias,
|
||||||
|
'canMoveFolder'=> $canMoveFolder,
|
||||||
'canEdit' => $canEdit,
|
'canEdit' => $canEdit,
|
||||||
'canCopy' => $canCopy,
|
'canCopy' => $canCopy,
|
||||||
'canExtract' => $canExtract,
|
'canExtract' => $canExtract,
|
||||||
|
|||||||
9
public/api/folder/moveFolder.php
Normal 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();
|
||||||
@@ -1046,11 +1046,6 @@ label {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#createFolderBtn {
|
|
||||||
margin-top: 0px !important;
|
|
||||||
height: 40px !important;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-actions {
|
.folder-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1058,6 +1053,7 @@ label {
|
|||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 600px) and (max-width: 992px) {
|
@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 {
|
.row-selected {
|
||||||
background-color: #f2f2f2 !important;
|
background-color: #f2f2f2 !important;
|
||||||
}
|
}
|
||||||
@@ -2318,11 +2378,14 @@ body.dark-mode { --perm-caret: #ccc; } /* dark */
|
|||||||
background-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,
|
||||||
#zonesToggleFloating .material-icons-outlined,
|
#zonesToggleFloating .material-icons-outlined,
|
||||||
#sidebarToggleFloating .material-icons,
|
#sidebarToggleFloating .material-icons,
|
||||||
#sidebarToggleFloating .material-icons-outlined {
|
#sidebarToggleFloating .material-icons-outlined {
|
||||||
color: #333 !important;
|
color: var(--toggle-icon-color);
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -286,9 +286,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="moveFolderBtn" class="btn btn-warning ml-2" data-i18n-title="move_folder">
|
||||||
|
<i class="material-icons">drive_file_move</i>
|
||||||
|
</button>
|
||||||
|
<!-- MOVE FOLDER MODAL (place near your other folder modals) -->
|
||||||
|
<div id="moveFolderModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h4 data-i18n-key="move_folder_title">Move Folder</h4>
|
||||||
|
<p data-i18n-key="move_folder_message">Select a destination folder to move the current folder
|
||||||
|
into:</p>
|
||||||
|
<select id="moveFolderTarget" class="form-control modal-input"></select>
|
||||||
|
<div class="modal-footer" style="margin-top:15px; text-align:right;">
|
||||||
|
<button id="cancelMoveFolder" class="btn btn-secondary"
|
||||||
|
data-i18n-key="cancel">Cancel</button>
|
||||||
|
<button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move">Move</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder">
|
<button id="renameFolderBtn" class="btn btn-warning ml-2" data-i18n-title="rename_folder">
|
||||||
<i class="material-icons">drive_file_rename_outline</i>
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="renameFolderModal" class="modal">
|
<div id="renameFolderModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h4 data-i18n-key="rename_folder_title">Rename Folder</h4>
|
<h4 data-i18n-key="rename_folder_title">Rename Folder</h4>
|
||||||
@@ -389,16 +407,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
|
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
|
||||||
data-i18n-key="download_zip">Download ZIP</button>
|
data-i18n-key="download_zip">Download ZIP</button>
|
||||||
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" data-i18n-title="extract_zip"
|
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" style="display: none;" disabled
|
||||||
data-i18n-key="extract_zip_button">Extract Zip</button>
|
data-i18n-key="extract_zip_button">Extract Zip</button>
|
||||||
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
|
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
|
||||||
<button id="createBtn" class="btn action-btn" data-i18n-key="create">
|
<button id="createBtn" class="btn action-btn" style="display: none;" data-i18n-key="create">
|
||||||
${t('create')} <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
${t('create')} <span class="material-icons"
|
||||||
</button>
|
style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
||||||
<ul
|
</button>
|
||||||
id="createMenu"
|
<ul id="createMenu" class="dropdown-menu" style="
|
||||||
class="dropdown-menu"
|
|
||||||
style="
|
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
@@ -411,27 +427,23 @@
|
|||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
"
|
">
|
||||||
>
|
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file"
|
||||||
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file" style="padding:8px 12px; cursor:pointer;">
|
style="padding:8px 12px; cursor:pointer;">
|
||||||
${t('create_file')}
|
${t('create_file')}
|
||||||
</li>
|
</li>
|
||||||
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder" style="padding:8px 12px; cursor:pointer;">
|
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder"
|
||||||
${t('create_folder')}
|
style="padding:8px 12px; cursor:pointer;">
|
||||||
</li>
|
${t('create_folder')}
|
||||||
</ul>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
|
</div>
|
||||||
<!-- Create File Modal -->
|
<!-- Create File Modal -->
|
||||||
<div id="createFileModal" class="modal" style="display:none;">
|
<div id="createFileModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h4 data-i18n-key="create_new_file">Create New File</h4>
|
<h4 data-i18n-key="create_new_file">Create New File</h4>
|
||||||
<input
|
<input type="text" id="createFileNameInput" class="form-control" placeholder="Enter filename…"
|
||||||
type="text"
|
data-i18n-placeholder="newfile_placeholder" />
|
||||||
id="createFileNameInput"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="Enter filename…"
|
|
||||||
data-i18n-placeholder="newfile_placeholder"
|
|
||||||
/>
|
|
||||||
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
|
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
|
||||||
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||||
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button>
|
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button>
|
||||||
@@ -563,6 +575,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="js/version.js"></script>
|
||||||
<script type="module" src="js/main.js"></script>
|
<script type="module" src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,19 @@ import { loadAdminConfigFunc } from './auth.js';
|
|||||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||||
import { sendRequest } from './networkUtils.js';
|
import { sendRequest } from './networkUtils.js';
|
||||||
|
|
||||||
const version = "v1.6.2";
|
const version = window.APP_VERSION || "dev";
|
||||||
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
||||||
|
|
||||||
|
|
||||||
|
function buildFullGrantsForAllFolders(folders) {
|
||||||
|
const allTrue = {
|
||||||
|
view:true, viewOwn:false, manage:true, create:true, upload:true, edit:true,
|
||||||
|
rename:true, copy:true, move:true, delete:true, extract:true,
|
||||||
|
shareFile:true, shareFolder:true, share:true
|
||||||
|
};
|
||||||
|
return folders.reduce((acc, f) => { acc[f] = { ...allTrue }; return acc; }, {});
|
||||||
|
}
|
||||||
|
|
||||||
/* === BEGIN: Folder Access helpers (merged + improved) === */
|
/* === BEGIN: Folder Access helpers (merged + improved) === */
|
||||||
function qs(scope, sel){ return (scope||document).querySelector(sel); }
|
function qs(scope, sel){ return (scope||document).querySelector(sel); }
|
||||||
function qsa(scope, sel){ return Array.from((scope||document).querySelectorAll(sel)); }
|
function qsa(scope, sel){ return Array.from((scope||document).querySelectorAll(sel)); }
|
||||||
@@ -50,7 +59,7 @@ function onShareFileToggle(row, checked) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onWriteToggle(row, checked) {
|
function onWriteToggle(row, checked) {
|
||||||
const caps = ["create","upload","edit","rename","copy","move","delete","extract"];
|
const caps = ["create","upload","edit","rename","copy","delete","extract"];
|
||||||
caps.forEach(c => {
|
caps.forEach(c => {
|
||||||
const box = qs(row, `input[data-cap="${c}"]`);
|
const box = qs(row, `input[data-cap="${c}"]`);
|
||||||
if (box) box.checked = checked;
|
if (box) box.checked = checked;
|
||||||
@@ -194,6 +203,25 @@ async function safeJson(res) {
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
|
.folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Folder cell: horizontal-only scroll */
|
||||||
|
.folder-cell{
|
||||||
|
overflow-x:auto;
|
||||||
|
overflow-y:hidden;
|
||||||
|
white-space:nowrap;
|
||||||
|
-webkit-overflow-scrolling:touch;
|
||||||
|
}
|
||||||
|
/* nicer thin scrollbar (supported browsers) */
|
||||||
|
.folder-cell::-webkit-scrollbar{ height:8px; }
|
||||||
|
.folder-cell::-webkit-scrollbar-thumb{ background:rgba(0,0,0,.25); border-radius:4px; }
|
||||||
|
body.dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); }
|
||||||
|
|
||||||
|
/* Badge now doesn't clip; let the wrapper handle scroll */
|
||||||
|
.folder-badge{
|
||||||
|
display:inline-flex; align-items:center; gap:6px;
|
||||||
|
font-weight:600;
|
||||||
|
min-width:0; /* allow child to be as wide as needed inside scroller */
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
})();
|
})();
|
||||||
@@ -617,21 +645,29 @@ export async function closeAdminPanel() {
|
|||||||
New: Folder Access (ACL) UI
|
New: Folder Access (ACL) UI
|
||||||
=========================== */
|
=========================== */
|
||||||
|
|
||||||
let __allFoldersCache = null; // array of folder strings
|
let __allFoldersCache = null;
|
||||||
async function getAllFolders() {
|
|
||||||
if (__allFoldersCache) return __allFoldersCache.slice();
|
async function getAllFolders(force = false) {
|
||||||
const res = await fetch('/api/folder/getFolderList.php', { credentials: 'include' });
|
if (!force && __allFoldersCache) return __allFoldersCache.slice();
|
||||||
const data = await safeJson(res).catch(() => []);
|
|
||||||
const list = Array.isArray(data)
|
const res = await fetch('/api/folder/getFolderList.php?ts=' + Date.now(), {
|
||||||
? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean)
|
credentials: 'include',
|
||||||
: [];
|
cache: 'no-store',
|
||||||
const hidden = new Set(["profile_pics", "trash"]);
|
headers: { 'Cache-Control': 'no-store' }
|
||||||
const cleaned = list
|
});
|
||||||
.filter(f => f && !hidden.has(f.toLowerCase()))
|
const data = await safeJson(res).catch(() => []);
|
||||||
.sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b)));
|
const list = Array.isArray(data)
|
||||||
__allFoldersCache = cleaned;
|
? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean)
|
||||||
return cleaned.slice();
|
: [];
|
||||||
}
|
|
||||||
|
const hidden = new Set(['profile_pics', 'trash']);
|
||||||
|
const cleaned = list
|
||||||
|
.filter(f => f && !hidden.has(f.toLowerCase()))
|
||||||
|
.sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b)));
|
||||||
|
|
||||||
|
__allFoldersCache = cleaned;
|
||||||
|
return cleaned.slice();
|
||||||
|
}
|
||||||
|
|
||||||
async function getUserGrants(username) {
|
async function getUserGrants(username) {
|
||||||
const res = await fetch(`/api/admin/acl/getGrants.php?user=${encodeURIComponent(username)}`, {
|
const res = await fetch(`/api/admin/acl/getGrants.php?user=${encodeURIComponent(username)}`, {
|
||||||
@@ -647,25 +683,32 @@ function renderFolderGrantsUI(username, container, folders, grants) {
|
|||||||
// toolbar
|
// toolbar
|
||||||
const toolbar = document.createElement('div');
|
const toolbar = document.createElement('div');
|
||||||
toolbar.className = 'folder-access-toolbar';
|
toolbar.className = 'folder-access-toolbar';
|
||||||
toolbar.innerHTML = `
|
toolbar.innerHTML = `
|
||||||
<input type="text" class="form-control" style="max-width:220px;" placeholder="${tf('search_folders', 'Search folders')}" />
|
<input type="text" class="form-control" style="max-width:220px;"
|
||||||
<label class="muted" title="${tf('view_all_help', 'See all files in this folder (everyone’s files)')}">
|
placeholder="${tf('search_folders', 'Search folders')}" />
|
||||||
<input type="checkbox" data-bulk="view" /> ${tf('view_all', 'View (all)')}
|
|
||||||
</label>
|
<label class="muted" title="${tf('view_all_help', 'See all files in this folder (everyone’s files)')}">
|
||||||
<label class="muted" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">
|
<input type="checkbox" data-bulk="view" /> ${tf('view_all', 'View (all)')}
|
||||||
<input type="checkbox" data-bulk="viewOwn" /> ${tf('view_own', 'View (own)')}
|
</label>
|
||||||
</label>
|
|
||||||
<label class="muted" title="${tf('write_help', 'Create/upload files and edit/rename/copy/delete items in this folder')}">
|
<label class="muted" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">
|
||||||
<input type="checkbox" data-bulk="write" /> ${tf('write_full', 'Write (upload/edit/delete)')}
|
<input type="checkbox" data-bulk="viewOwn" /> ${tf('view_own', 'View (own files)')}
|
||||||
</label>
|
</label>
|
||||||
<label class="muted" title="${tf('manage_help', 'Owner-level: can grant access; implies View (all)/Create Folder/Rename Folder/Move Files/Share Folder')}">
|
|
||||||
<input type="checkbox" data-bulk="manage" /> ${tf('manage', 'Manage')}
|
<label class="muted" title="${tf('write_help', 'File-level: upload, edit, rename, copy, delete, extract ZIPs')}">
|
||||||
</label>
|
<input type="checkbox" data-bulk="write" /> ${tf('write_full', 'Write (file ops)')}
|
||||||
<label class="muted" title="${tf('share_help', 'Create/manage share links; implies View (all)')}">
|
</label>
|
||||||
<input type="checkbox" data-bulk="share" /> ${tf('share', 'Share')}
|
|
||||||
</label>
|
<label class="muted" title="${tf('manage_help', 'Folder-level (owner): can create/rename/move folders and grant access; implies View (all)')}">
|
||||||
<span class="muted">(${tf('applies_to_filtered', 'applies to filtered list')})</span>
|
<input type="checkbox" data-bulk="manage" /> ${tf('manage', 'Manage (folder owner)')}
|
||||||
`;
|
</label>
|
||||||
|
|
||||||
|
<label class="muted" title="${tf('share_help', 'Create/manage share links; implies View (all)')}">
|
||||||
|
<input type="checkbox" data-bulk="share" /> ${tf('share', 'Share')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="muted">(${tf('applies_to_filtered', 'applies to filtered list')})</span>
|
||||||
|
`;
|
||||||
container.appendChild(toolbar);
|
container.appendChild(toolbar);
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
@@ -674,31 +717,64 @@ function renderFolderGrantsUI(username, container, folders, grants) {
|
|||||||
|
|
||||||
const headerHtml = `
|
const headerHtml = `
|
||||||
<div class="folder-access-header">
|
<div class="folder-access-header">
|
||||||
<div title="${tf('folder_help', 'Folder path within FileRise')}">${tf('folder', 'Folder')}</div>
|
<div class="folder-cell" title="${tf('folder_help','Folder path within FileRise')}">
|
||||||
<div class="perm-col" title="${tf('view_all_help', 'See all files in this folder (everyone’s files)')}">${tf('view_all', 'View (all)')}</div>
|
${tf('folder','Folder')}
|
||||||
<div class="perm-col" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">${tf('view_own', 'View (own)')}</div>
|
</div>
|
||||||
<div class="perm-col" title="${tf('write_help', 'Meta: toggles all write operations (below) on/off for this row')}">${tf('write_full', 'Write')}</div>
|
<div class="perm-col" title="${tf('view_all_help', 'See all files in this folder (everyone’s files)')}">
|
||||||
<div class="perm-col" title="${tf('manage_help', 'Owner-level: can grant access; implies View (all)/Create Folder/Rename Folder/Move Files/Share Folder')}">${tf('manage', 'Manage')}</div>
|
${tf('view_all', 'View (all)')}
|
||||||
<div class="perm-col" title="${tf('create_help', 'Create empty files')}">${tf('create', 'Create')}</div>
|
</div>
|
||||||
<div class="perm-col" title="${tf('upload_help', 'Upload files to this folder')}">${tf('upload', 'Upload')}</div>
|
<div class="perm-col" title="${tf('view_own_help', 'See only files you uploaded in this folder')}">
|
||||||
<div class="perm-col" title="${tf('edit_help', 'Edit file contents')}">${tf('edit', 'Edit')}</div>
|
${tf('view_own', 'View (own)')}
|
||||||
<div class="perm-col" title="${tf('rename_help', 'Rename files')}">${tf('rename', 'Rename')}</div>
|
</div>
|
||||||
<div class="perm-col" title="${tf('copy_help', 'Copy files')}">${tf('copy', 'Copy')}</div>
|
<div class="perm-col" title="${tf('write_help', 'Meta: toggles all file-level operations below')}">
|
||||||
<div class="perm-col" title="${tf('move_help', 'Move files: requires Manage')}">${tf('move', 'Move')}</div>
|
${tf('write_full', 'Write')}
|
||||||
<div class="perm-col" title="${tf('delete_help', 'Delete files/folders')}">${tf('delete', 'Delete')}</div>
|
</div>
|
||||||
<div class="perm-col" title="${tf('extract_help', 'Extract ZIP archives')}">${tf('extract', 'Extract ZIP')}</div>
|
<div class="perm-col" title="${tf('manage_help', 'Folder owner: can create/rename/move folders and grant access; implies View (all)')}">
|
||||||
<div class="perm-col" title="${tf('share_file_help', 'Create share links for files')}">${tf('share_file', 'Share File')}</div>
|
${tf('manage', 'Manage')}
|
||||||
<div class="perm-col" title="${tf('share_folder_help', 'Create share links for folders (requires View all)')}">${tf('share_folder', 'Share Folder')}</div>
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('create_help', 'Create empty file')}">
|
||||||
|
${tf('create', 'Create File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('upload_help', 'Upload a file into this folder')}">
|
||||||
|
${tf('upload', 'Upload File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('edit_help', 'Edit file contents')}">
|
||||||
|
${tf('edit', 'Edit File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('rename_help', 'Rename a file')}">
|
||||||
|
${tf('rename', 'Rename File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('copy_help', 'Copy a file')}">
|
||||||
|
${tf('copy', 'Copy File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('delete_help', 'Delete a file')}">
|
||||||
|
${tf('delete', 'Delete File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('extract_help', 'Extract ZIP archives')}">
|
||||||
|
${tf('extract', 'Extract ZIP')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('share_file_help', 'Create share links for files')}">
|
||||||
|
${tf('share_file', 'Share File')}
|
||||||
|
</div>
|
||||||
|
<div class="perm-col" title="${tf('share_folder_help', 'Create share links for folders (requires Manage + View (all))')}">
|
||||||
|
${tf('share_folder', 'Share Folder')}
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
function rowHtml(folder) {
|
function rowHtml(folder) {
|
||||||
const g = grants[folder] || {};
|
const g = grants[folder] || {};
|
||||||
const name = folder === 'root' ? '(Root)' : folder;
|
const name = folder === 'root' ? '(Root)' : folder;
|
||||||
const writeMetaChecked = !!(g.create || g.upload || g.edit || g.rename || g.copy || g.move || g.delete || g.extract);
|
const writeMetaChecked = !!(g.create || g.upload || g.edit || g.rename || g.copy || g.delete || g.extract);
|
||||||
const shareFolderDisabled = !g.view;
|
const shareFolderDisabled = !g.view;
|
||||||
return `
|
return `
|
||||||
<div class="folder-access-row" data-folder="${folder}">
|
<div class="folder-access-row" data-folder="${folder}">
|
||||||
<div class="folder-badge"><i class="material-icons" style="font-size:18px;">folder</i>${name}<span class="inherited-tag" style="display:none;"></span></div>
|
<div class="folder-cell">
|
||||||
|
<div class="folder-badge">
|
||||||
|
<i class="material-icons" style="font-size:18px;">folder</i>
|
||||||
|
${name}
|
||||||
|
<span class="inherited-tag" style="display:none;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="view" ${g.view ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="view" ${g.view ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="viewOwn" ${g.viewOwn ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="viewOwn" ${g.viewOwn ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="write" ${writeMetaChecked ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="write" ${writeMetaChecked ? 'checked' : ''}></div>
|
||||||
@@ -708,7 +784,6 @@ function renderFolderGrantsUI(username, container, folders, grants) {
|
|||||||
<div class="perm-col"><input type="checkbox" data-cap="edit" ${g.edit ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="edit" ${g.edit ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="rename" ${g.rename ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="rename" ${g.rename ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="copy" ${g.copy ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="copy" ${g.copy ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="move" ${g.move ? 'checked' : ''}></div>
|
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="delete" ${g.delete ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="delete" ${g.delete ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="extract" ${g.extract ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="extract" ${g.extract ? 'checked' : ''}></div>
|
||||||
<div class="perm-col"><input type="checkbox" data-cap="shareFile" ${g.shareFile ? 'checked' : ''}></div>
|
<div class="perm-col"><input type="checkbox" data-cap="shareFile" ${g.shareFile ? 'checked' : ''}></div>
|
||||||
@@ -744,7 +819,7 @@ function renderFolderGrantsUI(username, container, folders, grants) {
|
|||||||
if (v) v.checked = true;
|
if (v) v.checked = true;
|
||||||
if (w) w.checked = true;
|
if (w) w.checked = true;
|
||||||
if (vo) { vo.checked = false; vo.disabled = true; }
|
if (vo) { vo.checked = false; vo.disabled = true; }
|
||||||
['create','upload','edit','rename','copy','move','delete','extract','shareFile','shareFolder']
|
['create','upload','edit','rename','copy','delete','extract','shareFile','shareFolder']
|
||||||
.forEach(c => { const cb = qs(row, `input[data-cap="${c}"]`); if (cb) cb.checked = true; });
|
.forEach(c => { const cb = qs(row, `input[data-cap="${c}"]`); if (cb) cb.checked = true; });
|
||||||
setRowDisabled(row, true);
|
setRowDisabled(row, true);
|
||||||
const tag = row.querySelector('.inherited-tag');
|
const tag = row.querySelector('.inherited-tag');
|
||||||
@@ -844,7 +919,7 @@ function renderFolderGrantsUI(username, container, folders, grants) {
|
|||||||
const w = r.querySelector('input[data-cap="write"]');
|
const w = r.querySelector('input[data-cap="write"]');
|
||||||
const vo = r.querySelector('input[data-cap="viewOwn"]');
|
const vo = r.querySelector('input[data-cap="viewOwn"]');
|
||||||
const boxes = [
|
const boxes = [
|
||||||
'create','upload','edit','rename','copy','move','delete','extract','shareFile','shareFolder'
|
'create','upload','edit','rename','copy','delete','extract','shareFile','shareFolder'
|
||||||
].map(c => r.querySelector(`input[data-cap="${c}"]`));
|
].map(c => r.querySelector(`input[data-cap="${c}"]`));
|
||||||
if (m) m.checked = checked;
|
if (m) m.checked = checked;
|
||||||
if (v) v.checked = checked;
|
if (v) v.checked = checked;
|
||||||
@@ -999,15 +1074,16 @@ export function openUserPermissionsModal() {
|
|||||||
});
|
});
|
||||||
document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => {
|
document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => {
|
||||||
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
|
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
|
||||||
const changes = [];
|
const changes = [];
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
const username = String(row.getAttribute("data-username") || "").trim();
|
if (row.getAttribute("data-admin") === "1") return; // skip admins
|
||||||
if (!username) return;
|
const username = String(row.getAttribute("data-username") || "").trim();
|
||||||
const grantsBox = row.querySelector(".folder-grants-box");
|
if (!username) return;
|
||||||
if (!grantsBox || grantsBox.getAttribute('data-loaded') !== '1') return;
|
const grantsBox = row.querySelector(".folder-grants-box");
|
||||||
const grants = collectGrantsFrom(grantsBox);
|
if (!grantsBox || grantsBox.getAttribute('data-loaded') !== '1') return;
|
||||||
changes.push({ user: username, grants });
|
const grants = collectGrantsFrom(grantsBox);
|
||||||
});
|
changes.push({ user: username, grants });
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
if (changes.length === 0) { showToast(tf("nothing_to_save", "Nothing to save")); return; }
|
if (changes.length === 0) { showToast(tf("nothing_to_save", "Nothing to save")); return; }
|
||||||
await sendRequest("/api/admin/acl/saveGrants.php", "POST",
|
await sendRequest("/api/admin/acl/saveGrants.php", "POST",
|
||||||
@@ -1053,14 +1129,17 @@ async function fetchAllUserFlags() {
|
|||||||
function flagRow(u, flags) {
|
function flagRow(u, flags) {
|
||||||
const f = flags[u.username] || {};
|
const f = flags[u.username] || {};
|
||||||
const isAdmin = String(u.role) === "1" || u.username.toLowerCase() === "admin";
|
const isAdmin = String(u.role) === "1" || u.username.toLowerCase() === "admin";
|
||||||
if (isAdmin) return "";
|
|
||||||
|
const disabledAttr = isAdmin ? "disabled data-admin='1' title='Admin: full access'" : "";
|
||||||
|
const note = isAdmin ? " <span class='muted'>(Admin)</span>" : "";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr data-username="${u.username}">
|
<tr data-username="${u.username}" ${isAdmin ? "data-admin='1'" : ""}>
|
||||||
<td><strong>${u.username}</strong></td>
|
<td><strong>${u.username}</strong>${note}</td>
|
||||||
<td style="text-align:center;"><input type="checkbox" data-flag="readOnly" ${f.readOnly ? "checked" : ""}></td>
|
<td style="text-align:center;"><input type="checkbox" data-flag="readOnly" ${f.readOnly ? "checked" : ""} ${disabledAttr}></td>
|
||||||
<td style="text-align:center;"><input type="checkbox" data-flag="disableUpload" ${f.disableUpload ? "checked" : ""}></td>
|
<td style="text-align:center;"><input type="checkbox" data-flag="disableUpload" ${f.disableUpload ? "checked" : ""} ${disabledAttr}></td>
|
||||||
<td style="text-align:center;"><input type="checkbox" data-flag="canShare" ${f.canShare ? "checked" : ""}></td>
|
<td style="text-align:center;"><input type="checkbox" data-flag="canShare" ${f.canShare ? "checked" : ""} ${disabledAttr}></td>
|
||||||
<td style="text-align:center;"><input type="checkbox" data-flag="bypassOwnership" ${f.bypassOwnership ? "checked" : ""}></td>
|
<td style="text-align:center;"><input type="checkbox" data-flag="bypassOwnership" ${f.bypassOwnership ? "checked" : ""} ${disabledAttr}></td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1092,7 +1171,7 @@ export async function openUserFlagsModal() {
|
|||||||
|
|
||||||
<h3>${tf("user_permissions", "User Permissions")}</h3>
|
<h3>${tf("user_permissions", "User Permissions")}</h3>
|
||||||
<p class="muted" style="margin-top:-6px;">
|
<p class="muted" style="margin-top:-6px;">
|
||||||
${tf("user_flags_help", "Account-level switches. These are NOT per-folder grants.")}
|
${tf("user_flags_help", "Non Admin User Account-level switches. These are NOT per-folder grants.")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div id="userFlagsBody"
|
<div id="userFlagsBody"
|
||||||
@@ -1158,6 +1237,7 @@ async function saveUserFlags() {
|
|||||||
const rows = body?.querySelectorAll("tbody tr[data-username]") || [];
|
const rows = body?.querySelectorAll("tbody tr[data-username]") || [];
|
||||||
const permissions = [];
|
const permissions = [];
|
||||||
rows.forEach(tr => {
|
rows.forEach(tr => {
|
||||||
|
if (tr.getAttribute("data-admin") === "1") return; // don't send admin updates
|
||||||
const username = tr.getAttribute("data-username");
|
const username = tr.getAttribute("data-username");
|
||||||
const get = k => tr.querySelector(`input[data-flag="${k}"]`).checked;
|
const get = k => tr.querySelector(`input[data-flag="${k}"]`).checked;
|
||||||
permissions.push({
|
permissions.push({
|
||||||
@@ -1201,61 +1281,73 @@ async function loadUserPermissionsList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folders = await getAllFolders();
|
const folders = await getAllFolders(true);
|
||||||
|
|
||||||
listContainer.innerHTML = "";
|
listContainer.innerHTML = "";
|
||||||
users.forEach(user => {
|
users.forEach(user => {
|
||||||
if ((user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin") return;
|
const isAdmin = (user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin";
|
||||||
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.classList.add("user-permission-row");
|
row.classList.add("user-permission-row");
|
||||||
row.setAttribute("data-username", user.username);
|
row.setAttribute("data-username", user.username);
|
||||||
row.style.padding = "6px 0";
|
if (isAdmin) row.setAttribute("data-admin", "1"); // mark admins
|
||||||
|
row.style.padding = "6px 0";
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="user-perm-header" tabindex="0" role="button" aria-expanded="false"
|
<div class="user-perm-header" tabindex="0" role="button" aria-expanded="false"
|
||||||
style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 8px;border-radius:6px;">
|
style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 8px;border-radius:6px;">
|
||||||
<span class="perm-caret" style="display:inline-block; transform: rotate(-90deg); transition: transform 120ms ease;">▸</span>
|
<span class="perm-caret" style="display:inline-block; transform: rotate(-90deg); transition: transform 120ms ease;">▸</span>
|
||||||
<strong>${user.username}</strong>
|
<strong>${user.username}</strong>
|
||||||
<span class="muted" style="margin-left:auto;">${tf('click_to_edit', 'Click to edit')}</span>
|
${isAdmin ? `<span class="muted" style="margin-left:auto;">Admin (full access)</span>`
|
||||||
</div>
|
: `<span class="muted" style="margin-left:auto;">${tf('click_to_edit', 'Click to edit')}</span>`}
|
||||||
<div class="user-perm-details" style="display:none; margin:8px 0 12px;">
|
</div>
|
||||||
<div class="folder-grants-box" data-loaded="0"></div>
|
<div class="user-perm-details" style="display:none; margin:8px 0 12px;">
|
||||||
</div>
|
<div class="folder-grants-box" data-loaded="0"></div>
|
||||||
`;
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
const header = row.querySelector(".user-perm-header");
|
const header = row.querySelector(".user-perm-header");
|
||||||
const details = row.querySelector(".user-perm-details");
|
const details = row.querySelector(".user-perm-details");
|
||||||
const caret = row.querySelector(".perm-caret");
|
const caret = row.querySelector(".perm-caret");
|
||||||
const grantsBox = row.querySelector(".folder-grants-box");
|
const grantsBox = row.querySelector(".folder-grants-box");
|
||||||
|
|
||||||
async function ensureLoaded() {
|
async function ensureLoaded() {
|
||||||
if (grantsBox.dataset.loaded === "1") return;
|
if (grantsBox.dataset.loaded === "1") return;
|
||||||
try {
|
try {
|
||||||
const grants = await getUserGrants(user.username);
|
let grants;
|
||||||
renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], grants);
|
if (isAdmin) {
|
||||||
grantsBox.dataset.loaded = "1";
|
// synthesize full access
|
||||||
} catch (e) {
|
const ordered = ["root", ...folders.filter(f => f !== "root")];
|
||||||
console.error(e);
|
grants = buildFullGrantsForAllFolders(ordered);
|
||||||
grantsBox.innerHTML = `<div class="muted">${tf("error_loading_user_grants", "Error loading user grants")}</div>`;
|
renderFolderGrantsUI(user.username, grantsBox, ordered, grants);
|
||||||
}
|
// disable all inputs
|
||||||
|
grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.disabled = true);
|
||||||
|
} else {
|
||||||
|
const userGrants = await getUserGrants(user.username);
|
||||||
|
renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], userGrants);
|
||||||
}
|
}
|
||||||
|
grantsBox.dataset.loaded = "1";
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
grantsBox.innerHTML = `<div class="muted">${tf("error_loading_user_grants", "Error loading user grants")}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleOpen() {
|
function toggleOpen() {
|
||||||
const willShow = details.style.display === "none";
|
const willShow = details.style.display === "none";
|
||||||
details.style.display = willShow ? "block" : "none";
|
details.style.display = willShow ? "block" : "none";
|
||||||
header.setAttribute("aria-expanded", willShow ? "true" : "false");
|
header.setAttribute("aria-expanded", willShow ? "true" : "false");
|
||||||
caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)";
|
caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)";
|
||||||
if (willShow) ensureLoaded();
|
if (willShow) ensureLoaded();
|
||||||
}
|
}
|
||||||
|
|
||||||
header.addEventListener("click", toggleOpen);
|
header.addEventListener("click", toggleOpen);
|
||||||
header.addEventListener("keydown", e => {
|
header.addEventListener("keydown", e => {
|
||||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); }
|
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
listContainer.appendChild(row);
|
listContainer.appendChild(row);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
|
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ async function applyFolderCapabilities(folder) {
|
|||||||
|
|
||||||
const isRoot = (folder === 'root');
|
const isRoot = (folder === 'root');
|
||||||
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
|
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
|
||||||
|
setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder);
|
||||||
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
|
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
|
||||||
setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete);
|
setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete);
|
||||||
setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder);
|
setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder);
|
||||||
@@ -180,6 +181,49 @@ function breadcrumbDropHandler(e) {
|
|||||||
console.error("Invalid drag data on breadcrumb:", err);
|
console.error("Invalid drag data on breadcrumb:", err);
|
||||||
return;
|
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] : []);
|
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||||
if (filesToMove.length === 0) return;
|
if (filesToMove.length === 0) return;
|
||||||
|
|
||||||
@@ -262,7 +306,7 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
|||||||
} else {
|
} else {
|
||||||
html += `<span class="folder-indent-placeholder"></span>`;
|
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) {
|
if (hasChildren) {
|
||||||
html += renderFolderTree(tree[folder], fullPath, displayState);
|
html += renderFolderTree(tree[folder], fullPath, displayState);
|
||||||
}
|
}
|
||||||
@@ -312,13 +356,58 @@ function folderDropHandler(event) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.classList.remove("drop-hover");
|
event.currentTarget.classList.remove("drop-hover");
|
||||||
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
||||||
let dragData;
|
let dragData = null;
|
||||||
try {
|
try {
|
||||||
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
|
const jsonStr = event.dataTransfer.getData("application/json") || "";
|
||||||
} catch (e) {
|
if (jsonStr) dragData = JSON.parse(jsonStr);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
console.error("Invalid drag data", e);
|
console.error("Invalid drag data", e);
|
||||||
return;
|
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] : []);
|
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||||
if (filesToMove.length === 0) return;
|
if (filesToMove.length === 0) return;
|
||||||
|
|
||||||
@@ -459,6 +548,14 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
|
|
||||||
// Attach drag/drop event listeners.
|
// Attach drag/drop event listeners.
|
||||||
container.querySelectorAll(".folder-option").forEach(el => {
|
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("dragover", folderDragOverHandler);
|
||||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||||
el.addEventListener("drop", folderDropHandler);
|
el.addEventListener("drop", folderDropHandler);
|
||||||
@@ -487,6 +584,14 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
|
|
||||||
// Folder-option click: update selection, breadcrumbs, and file list
|
// Folder-option click: update selection, breadcrumbs, and file list
|
||||||
container.querySelectorAll(".folder-option").forEach(el => {
|
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) {
|
el.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
||||||
@@ -642,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() {
|
export function openDeleteFolderModal() {
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
if (!selectedFolder || selectedFolder === "root") {
|
if (!selectedFolder || selectedFolder === "root") {
|
||||||
@@ -841,6 +984,10 @@ function folderManagerContextMenuHandler(e) {
|
|||||||
if (input) input.focus();
|
if (input) input.focus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("move_folder"),
|
||||||
|
action: () => { openMoveFolderUI(folder); }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t("rename_folder"),
|
label: t("rename_folder"),
|
||||||
action: () => { openRenameFolderModal(); }
|
action: () => { openRenameFolderModal(); }
|
||||||
@@ -923,4 +1070,53 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initial context menu delegation bind
|
// 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'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -282,7 +282,27 @@ const translations = {
|
|||||||
"bypass_ownership": "Bypass Ownership",
|
"bypass_ownership": "Bypass Ownership",
|
||||||
"error_loading_user_grants": "Error loading user grants",
|
"error_loading_user_grants": "Error loading user grants",
|
||||||
"click_to_edit": "Click to edit",
|
"click_to_edit": "Click to edit",
|
||||||
"folder_access": "Folder Access"
|
"folder_access": "Folder Access",
|
||||||
|
"move_folder": "Move Folder",
|
||||||
|
"move_folder_message": "Select a destination folder to move this folder to:",
|
||||||
|
"move_folder_title": "Move this folder",
|
||||||
|
"move_folder_success": "Folder moved successfully.",
|
||||||
|
"move_folder_error": "Error moving folder.",
|
||||||
|
"move_folder_invalid": "Invalid source or destination folder.",
|
||||||
|
"move_folder_denied": "You do not have permission to move this folder.",
|
||||||
|
"move_folder_same_dest": "Destination cannot be the source or one of its subfolders.",
|
||||||
|
"move_folder_same_owner": "Source and destination must have the same owner.",
|
||||||
|
"move_folder_confirm": "Are you sure you want to move this folder?",
|
||||||
|
"move_folder_select_dest": "Select a destination folder",
|
||||||
|
"move_folder_select_dest_help": "Choose where this folder should be moved to.",
|
||||||
|
"acl_move_folder_label": "Move Folder (source)",
|
||||||
|
"acl_move_folder_help": "Allows moving this folder to a different parent. Requires Manage or Ownership on the folder.",
|
||||||
|
"acl_move_in_label": "Allow Moves Into This Folder (destination)",
|
||||||
|
"acl_move_in_help": "Allows items or folders from elsewhere to be moved into this folder. Requires Manage on the destination folder.",
|
||||||
|
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
|
||||||
|
"context_move_folder": "Move Folder...",
|
||||||
|
"context_move_here": "Move Here",
|
||||||
|
"context_move_cancel": "Cancel Move"
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||||
|
|||||||
@@ -105,12 +105,14 @@ export function initializeApp() {
|
|||||||
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
||||||
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
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');
|
const stored = localStorage.getItem('showFoldersInList');
|
||||||
window.showFoldersInList = stored === null ? true : stored === 'true';
|
window.showFoldersInList = stored === null ? true : stored === 'true';
|
||||||
loadAdminConfigFunc();
|
loadAdminConfigFunc();
|
||||||
initTagSearch();
|
initTagSearch();
|
||||||
loadFileList(window.currentFolder);
|
//loadFileList(window.currentFolder);
|
||||||
|
|
||||||
const fileListArea = document.getElementById('fileListContainer');
|
const fileListArea = document.getElementById('fileListContainer');
|
||||||
const uploadArea = document.getElementById('uploadDropArea');
|
const uploadArea = document.getElementById('uploadDropArea');
|
||||||
|
|||||||
@@ -161,91 +161,91 @@ function createFileEntry(file) {
|
|||||||
const removeBtn = document.createElement("button");
|
const removeBtn = document.createElement("button");
|
||||||
removeBtn.classList.add("remove-file-btn");
|
removeBtn.classList.add("remove-file-btn");
|
||||||
removeBtn.textContent = "×";
|
removeBtn.textContent = "×";
|
||||||
// In your remove button event listener, replace the fetch call with:
|
// In your remove button event listener, replace the fetch call with:
|
||||||
removeBtn.addEventListener("click", function (e) {
|
removeBtn.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const uploadIndex = file.uploadIndex;
|
const uploadIndex = file.uploadIndex;
|
||||||
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
|
window.selectedFiles = window.selectedFiles.filter(f => f.uploadIndex !== uploadIndex);
|
||||||
|
|
||||||
// Cancel the file upload if possible.
|
// Cancel the file upload if possible.
|
||||||
if (typeof file.cancel === "function") {
|
if (typeof file.cancel === "function") {
|
||||||
file.cancel();
|
file.cancel();
|
||||||
console.log("Canceled file upload:", file.fileName);
|
console.log("Canceled file upload:", file.fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove file from the resumable queue.
|
// Remove file from the resumable queue.
|
||||||
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
|
if (resumableInstance && typeof resumableInstance.removeFile === "function") {
|
||||||
resumableInstance.removeFile(file);
|
resumableInstance.removeFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call our helper repeatedly to remove the chunk folder.
|
// Call our helper repeatedly to remove the chunk folder.
|
||||||
if (file.uniqueIdentifier) {
|
if (file.uniqueIdentifier) {
|
||||||
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
|
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
li.remove();
|
li.remove();
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
});
|
});
|
||||||
li.removeBtn = removeBtn;
|
li.removeBtn = removeBtn;
|
||||||
li.appendChild(removeBtn);
|
li.appendChild(removeBtn);
|
||||||
|
|
||||||
// Add pause/resume/restart button if the file supports pause/resume.
|
// Add pause/resume/restart button if the file supports pause/resume.
|
||||||
// Conditionally add the pause/resume button only if file.pause is available
|
// Conditionally add the pause/resume button only if file.pause is available
|
||||||
// Pause/Resume button (for resumable file–picker uploads)
|
// Pause/Resume button (for resumable file–picker uploads)
|
||||||
if (typeof file.pause === "function") {
|
if (typeof file.pause === "function") {
|
||||||
const pauseResumeBtn = document.createElement("button");
|
const pauseResumeBtn = document.createElement("button");
|
||||||
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
|
pauseResumeBtn.setAttribute("type", "button"); // not a submit button
|
||||||
pauseResumeBtn.classList.add("pause-resume-btn");
|
pauseResumeBtn.classList.add("pause-resume-btn");
|
||||||
// Start with pause icon and disable button until upload starts
|
// Start with pause icon and disable button until upload starts
|
||||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||||
pauseResumeBtn.disabled = true;
|
pauseResumeBtn.disabled = true;
|
||||||
pauseResumeBtn.addEventListener("click", function (e) {
|
pauseResumeBtn.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (file.isError) {
|
if (file.isError) {
|
||||||
// If the file previously failed, try restarting upload.
|
// If the file previously failed, try restarting upload.
|
||||||
if (typeof file.retry === "function") {
|
if (typeof file.retry === "function") {
|
||||||
file.retry();
|
file.retry();
|
||||||
file.isError = false;
|
file.isError = false;
|
||||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||||
}
|
}
|
||||||
} else if (!file.paused) {
|
} else if (!file.paused) {
|
||||||
// Pause the upload (if possible)
|
// Pause the upload (if possible)
|
||||||
if (typeof file.pause === "function") {
|
|
||||||
file.pause();
|
|
||||||
file.paused = true;
|
|
||||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
} else if (file.paused) {
|
|
||||||
// Resume sequence: first call to resume (or upload() fallback)
|
|
||||||
if (typeof file.resume === "function") {
|
|
||||||
file.resume();
|
|
||||||
} else {
|
|
||||||
resumableInstance.upload();
|
|
||||||
}
|
|
||||||
// After a short delay, pause again then resume
|
|
||||||
setTimeout(() => {
|
|
||||||
if (typeof file.pause === "function") {
|
if (typeof file.pause === "function") {
|
||||||
file.pause();
|
file.pause();
|
||||||
|
file.paused = true;
|
||||||
|
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">play_circle_outline</span>';
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
} else if (file.paused) {
|
||||||
|
// Resume sequence: first call to resume (or upload() fallback)
|
||||||
|
if (typeof file.resume === "function") {
|
||||||
|
file.resume();
|
||||||
} else {
|
} else {
|
||||||
resumableInstance.upload();
|
resumableInstance.upload();
|
||||||
}
|
}
|
||||||
|
// After a short delay, pause again then resume
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof file.resume === "function") {
|
if (typeof file.pause === "function") {
|
||||||
file.resume();
|
file.pause();
|
||||||
} else {
|
} else {
|
||||||
resumableInstance.upload();
|
resumableInstance.upload();
|
||||||
}
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof file.resume === "function") {
|
||||||
|
file.resume();
|
||||||
|
} else {
|
||||||
|
resumableInstance.upload();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
}, 100);
|
}, 100);
|
||||||
}, 100);
|
file.paused = false;
|
||||||
file.paused = false;
|
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
||||||
pauseResumeBtn.innerHTML = '<span class="material-icons pauseResumeBtn">pause_circle_outline</span>';
|
} else {
|
||||||
} else {
|
console.error("Pause/resume function not available for file", file);
|
||||||
console.error("Pause/resume function not available for file", file);
|
}
|
||||||
}
|
});
|
||||||
});
|
li.appendChild(pauseResumeBtn);
|
||||||
li.appendChild(pauseResumeBtn);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Preview element
|
// Preview element
|
||||||
const preview = document.createElement("div");
|
const preview = document.createElement("div");
|
||||||
@@ -406,20 +406,27 @@ let resumableInstance;
|
|||||||
function initResumableUpload() {
|
function initResumableUpload() {
|
||||||
resumableInstance = new Resumable({
|
resumableInstance = new Resumable({
|
||||||
target: "/api/upload/upload.php",
|
target: "/api/upload/upload.php",
|
||||||
query: { folder: window.currentFolder || "root", upload_token: window.csrfToken },
|
chunkSize: 1.5 * 1024 * 1024,
|
||||||
chunkSize: 1.5 * 1024 * 1024, // 1.5 MB chunks
|
|
||||||
simultaneousUploads: 3,
|
simultaneousUploads: 3,
|
||||||
forceChunkSize: true,
|
forceChunkSize: true,
|
||||||
testChunks: false,
|
testChunks: false,
|
||||||
throttleProgressCallbacks: 1,
|
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: { 'X-CSRF-Token': window.csrfToken },
|
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||||
query: {
|
query: () => ({
|
||||||
folder: window.currentFolder || "root",
|
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");
|
const fileInput = document.getElementById("file");
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
// Assign Resumable to file input for file picker uploads.
|
// Assign Resumable to file input for file picker uploads.
|
||||||
@@ -432,6 +439,7 @@ function initResumableUpload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resumableInstance.on("fileAdded", function (file) {
|
resumableInstance.on("fileAdded", function (file) {
|
||||||
|
|
||||||
// Initialize custom paused flag
|
// Initialize custom paused flag
|
||||||
file.paused = false;
|
file.paused = false;
|
||||||
file.uploadIndex = file.uniqueIdentifier;
|
file.uploadIndex = file.uniqueIdentifier;
|
||||||
@@ -461,16 +469,17 @@ function initResumableUpload() {
|
|||||||
li.dataset.uploadIndex = file.uniqueIdentifier;
|
li.dataset.uploadIndex = file.uniqueIdentifier;
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
|
updateResumableQuery();
|
||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("fileProgress", function(file) {
|
resumableInstance.on("fileProgress", function (file) {
|
||||||
const progress = file.progress(); // value between 0 and 1
|
const progress = file.progress(); // value between 0 and 1
|
||||||
const percent = Math.floor(progress * 100);
|
const percent = Math.floor(progress * 100);
|
||||||
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
|
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
|
||||||
if (li && li.progressBar) {
|
if (li && li.progressBar) {
|
||||||
if (percent < 99) {
|
if (percent < 99) {
|
||||||
li.progressBar.style.width = percent + "%";
|
li.progressBar.style.width = percent + "%";
|
||||||
|
|
||||||
// Calculate elapsed time and speed.
|
// Calculate elapsed time and speed.
|
||||||
const elapsed = (Date.now() - li.startTime) / 1000;
|
const elapsed = (Date.now() - li.startTime) / 1000;
|
||||||
let speed = "";
|
let speed = "";
|
||||||
@@ -491,7 +500,7 @@ function initResumableUpload() {
|
|||||||
li.progressBar.style.width = "100%";
|
li.progressBar.style.width = "100%";
|
||||||
li.progressBar.innerHTML = '<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
|
li.progressBar.innerHTML = '<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable the pause/resume button once progress starts.
|
// Enable the pause/resume button once progress starts.
|
||||||
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
||||||
if (pauseResumeBtn) {
|
if (pauseResumeBtn) {
|
||||||
@@ -499,8 +508,8 @@ function initResumableUpload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("fileSuccess", function(file, message) {
|
resumableInstance.on("fileSuccess", function (file, message) {
|
||||||
// Try to parse JSON response
|
// Try to parse JSON response
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
@@ -508,18 +517,18 @@ function initResumableUpload() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
data = null;
|
data = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Soft‐fail CSRF? then update token & retry this file
|
// 1) Soft‐fail CSRF? then update token & retry this file
|
||||||
if (data && data.csrf_expired) {
|
if (data && data.csrf_expired) {
|
||||||
// Update global and Resumable headers
|
// Update global and Resumable headers
|
||||||
window.csrfToken = data.csrf_token;
|
window.csrfToken = data.csrf_token;
|
||||||
resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token;
|
resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token;
|
||||||
resumableInstance.opts.query.upload_token = data.csrf_token;
|
resumableInstance.opts.query.upload_token = data.csrf_token;
|
||||||
// Retry this chunk/file
|
// Retry this chunk/file
|
||||||
file.retry();
|
file.retry();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Otherwise treat as real success:
|
// 2) Otherwise treat as real success:
|
||||||
const li = document.querySelector(
|
const li = document.querySelector(
|
||||||
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
|
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
|
||||||
@@ -531,13 +540,13 @@ function initResumableUpload() {
|
|||||||
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
||||||
if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
|
if (pauseResumeBtn) pauseResumeBtn.style.display = "none";
|
||||||
const removeBtn = li.querySelector(".remove-file-btn");
|
const removeBtn = li.querySelector(".remove-file-btn");
|
||||||
if (removeBtn) removeBtn.style.display = "none";
|
if (removeBtn) removeBtn.style.display = "none";
|
||||||
setTimeout(() => li.remove(), 5000);
|
setTimeout(() => li.remove(), 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
resumableInstance.on("fileError", function (file, message) {
|
resumableInstance.on("fileError", function (file, message) {
|
||||||
@@ -637,7 +646,7 @@ function submitFiles(allFiles) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
jsonResponse = null;
|
jsonResponse = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Soft-fail CSRF: retry this upload ───────────────────────
|
// ─── Soft-fail CSRF: retry this upload ───────────────────────
|
||||||
if (jsonResponse && jsonResponse.csrf_expired) {
|
if (jsonResponse && jsonResponse.csrf_expired) {
|
||||||
console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
|
console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex);
|
||||||
@@ -650,10 +659,10 @@ function submitFiles(allFiles) {
|
|||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
return; // skip the "finishedCount++" and error/success logic for now
|
return; // skip the "finishedCount++" and error/success logic for now
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Normal success/error handling ────────────────────────────
|
// ─── Normal success/error handling ────────────────────────────
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
||||||
// real success
|
// real success
|
||||||
if (li) {
|
if (li) {
|
||||||
@@ -662,6 +671,7 @@ function submitFiles(allFiles) {
|
|||||||
if (li.removeBtn) li.removeBtn.style.display = "none";
|
if (li.removeBtn) li.removeBtn.style.display = "none";
|
||||||
}
|
}
|
||||||
uploadResults[file.uploadIndex] = true;
|
uploadResults[file.uploadIndex] = true;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// real failure
|
// real failure
|
||||||
if (li) {
|
if (li) {
|
||||||
@@ -681,12 +691,17 @@ function submitFiles(allFiles) {
|
|||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Only now count this chunk as finished ───────────────────
|
// ─── Only now count this chunk as finished ───────────────────
|
||||||
finishedCount++;
|
finishedCount++;
|
||||||
if (finishedCount === allFiles.length) {
|
if (finishedCount === allFiles.length) {
|
||||||
refreshFileList(allFiles, uploadResults, progressElements);
|
const succeededCount = uploadResults.filter(Boolean).length;
|
||||||
}
|
const failedCount = allFiles.length - succeededCount;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshFileList(allFiles, uploadResults, progressElements);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener("error", function () {
|
xhr.addEventListener("error", function () {
|
||||||
@@ -699,6 +714,9 @@ function submitFiles(allFiles) {
|
|||||||
finishedCount++;
|
finishedCount++;
|
||||||
if (finishedCount === allFiles.length) {
|
if (finishedCount === allFiles.length) {
|
||||||
refreshFileList(allFiles, uploadResults, progressElements);
|
refreshFileList(allFiles, uploadResults, progressElements);
|
||||||
|
// Immediate summary toast based on actual XHR outcomes
|
||||||
|
const succeededCount = uploadResults.filter(Boolean).length;
|
||||||
|
const failedCount = allFiles.length - succeededCount;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -725,17 +743,30 @@ function submitFiles(allFiles) {
|
|||||||
loadFileList(folderToUse)
|
loadFileList(folderToUse)
|
||||||
.then(serverFiles => {
|
.then(serverFiles => {
|
||||||
initFileActions();
|
initFileActions();
|
||||||
serverFiles = (serverFiles || []).map(item => item.name.trim().toLowerCase());
|
// Be tolerant to API shapes: string or object with name/fileName/filename
|
||||||
|
serverFiles = (serverFiles || [])
|
||||||
|
.map(item => {
|
||||||
|
if (typeof item === 'string') return item;
|
||||||
|
const n = item?.name ?? item?.fileName ?? item?.filename ?? '';
|
||||||
|
return String(n);
|
||||||
|
})
|
||||||
|
.map(s => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
let overallSuccess = true;
|
let overallSuccess = true;
|
||||||
|
let succeeded = 0;
|
||||||
allFiles.forEach(file => {
|
allFiles.forEach(file => {
|
||||||
const clientFileName = file.name.trim().toLowerCase();
|
const clientFileName = file.name.trim().toLowerCase();
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
if (!uploadResults[file.uploadIndex] || !serverFiles.includes(clientFileName)) {
|
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
|
||||||
|
if (!uploadResults[file.uploadIndex] || (!hadRelative && !serverFiles.includes(clientFileName))) {
|
||||||
if (li) {
|
if (li) {
|
||||||
li.progressBar.innerText = "Error";
|
li.progressBar.innerText = "Error";
|
||||||
}
|
}
|
||||||
overallSuccess = false;
|
overallSuccess = false;
|
||||||
|
|
||||||
} else if (li) {
|
} else if (li) {
|
||||||
|
succeeded++;
|
||||||
|
|
||||||
// Schedule removal of successful file entry after 5 seconds.
|
// Schedule removal of successful file entry after 5 seconds.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
li.remove();
|
li.remove();
|
||||||
@@ -757,9 +788,12 @@ function submitFiles(allFiles) {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!overallSuccess) {
|
if (!overallSuccess) {
|
||||||
showToast("Some files failed to upload. Please check the list.");
|
const failed = allFiles.length - succeeded;
|
||||||
|
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
|
||||||
|
} else {
|
||||||
|
showToast(`${succeeded} file succeeded. Please check the list.`);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -768,6 +802,7 @@ function submitFiles(allFiles) {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
loadFolderTree(window.currentFolder);
|
loadFolderTree(window.currentFolder);
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
public/js/version.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// generated by CI
|
||||||
|
window.APP_VERSION = 'v1.6.8';
|
||||||
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 764 KiB After Width: | Height: | Size: 470 KiB |
BIN
resources/dark-folder-access.png
Normal file
|
After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 736 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 623 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 521 KiB |
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 552 KiB |
|
Before Width: | Height: | Size: 369 KiB After Width: | Height: | Size: 608 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 538 KiB |
|
Before Width: | Height: | Size: 504 KiB After Width: | Height: | Size: 610 KiB |
|
Before Width: | Height: | Size: 426 KiB After Width: | Height: | Size: 554 KiB |
@@ -501,7 +501,7 @@ public function deleteFiles()
|
|||||||
$userPermissions = $this->loadPerms($username);
|
$userPermissions = $this->loadPerms($username);
|
||||||
|
|
||||||
// Need granular rename (or ancestor-owner)
|
// Need granular rename (or ancestor-owner)
|
||||||
if (!(ACL::canRename($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
if (!(ACL::canRename($username, $userPermissions, $folder))) {
|
||||||
$this->_jsonOut(["error"=>"Forbidden: no rename rights"], 403); return;
|
$this->_jsonOut(["error"=>"Forbidden: no rename rights"], 403); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -695,4 +695,79 @@ for ($i = $startPage; $i <= $endPage; $i++): ?>
|
|||||||
echo json_encode(['success' => false, 'error' => 'Not found']);
|
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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,48 @@ class ACL
|
|||||||
unset($rec);
|
unset($rec);
|
||||||
return $changed ? self::save($acl) : true;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
private static function loadFresh(): array {
|
private static function loadFresh(): array {
|
||||||
$path = self::path();
|
$path = self::path();
|
||||||
@@ -323,10 +365,10 @@ class ACL
|
|||||||
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
|
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
|
||||||
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
|
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
|
||||||
|
|
||||||
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $mv = $dl = $ex = $sf = $sfo = true; }
|
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; }
|
||||||
if ($u && !$v && !$vo) $vo = true;
|
if ($u && !$v && !$vo) $vo = true;
|
||||||
//if ($s && !$v) $v = true;
|
//if ($s && !$v) $v = true;
|
||||||
if ($w) { $c = $u = $ed = $rn = $cp = $mv = $dl = $ex = true; }
|
if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; }
|
||||||
|
|
||||||
if ($m) $rec['owners'][] = $user;
|
if ($m) $rec['owners'][] = $user;
|
||||||
if ($v) $rec['read'][] = $user;
|
if ($v) $rec['read'][] = $user;
|
||||||
@@ -419,9 +461,13 @@ public static function canCopy(string $user, array $perms, string $folder): bool
|
|||||||
public static function canMove(string $user, array $perms, string $folder): bool {
|
public static function canMove(string $user, array $perms, string $folder): bool {
|
||||||
$folder = self::normalizeFolder($folder);
|
$folder = self::normalizeFolder($folder);
|
||||||
if (self::isAdmin($perms)) return true;
|
if (self::isAdmin($perms)) return true;
|
||||||
return self::hasGrant($user, $folder, 'owners')
|
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||||
|| self::hasGrant($user, $folder, 'move')
|
}
|
||||||
|| self::hasGrant($user, $folder, 'write');
|
|
||||||
|
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 {
|
public static function canDelete(string $user, array $perms, string $folder): bool {
|
||||||
|
|||||||
@@ -326,6 +326,8 @@ class FolderModel
|
|||||||
|
|
||||||
// Update ownership mapping for the entire subtree.
|
// Update ownership mapping for the entire subtree.
|
||||||
self::renameOwnersForTree($oldRel, $newRel);
|
self::renameOwnersForTree($oldRel, $newRel);
|
||||||
|
// Re-key explicit ACLs for the moved subtree
|
||||||
|
ACL::renameTree($oldRel, $newRel);
|
||||||
|
|
||||||
return ["success" => true];
|
return ["success" => true];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,19 @@
|
|||||||
require_once PROJECT_ROOT . '/config/config.php';
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
|
||||||
class UploadModel {
|
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.
|
* Handles file uploads – supports both chunked uploads and full (non-chunked) uploads.
|
||||||
*
|
*
|
||||||
@@ -38,15 +51,19 @@ class UploadModel {
|
|||||||
return ["error" => "Invalid file name: $resumableFilename"];
|
return ["error" => "Invalid file name: $resumableFilename"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
|
$folderRaw = $post['folder'] ?? 'root';
|
||||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
$folderSan = self::sanitizeFolder((string)$folderRaw);
|
||||||
return ["error" => "Invalid folder name"];
|
|
||||||
}
|
|
||||||
|
if (empty($files['file']) || !isset($files['file']['name'])) {
|
||||||
|
return ["error" => "No files received"];
|
||||||
|
}
|
||||||
|
|
||||||
$baseUploadDir = UPLOAD_DIR;
|
$baseUploadDir = UPLOAD_DIR;
|
||||||
if ($folder !== 'root') {
|
if ($folderSan !== '') {
|
||||||
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
|
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
||||||
}
|
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
||||||
return ["error" => "Failed to create upload directory"];
|
return ["error" => "Failed to create upload directory"];
|
||||||
}
|
}
|
||||||
@@ -56,12 +73,14 @@ class UploadModel {
|
|||||||
return ["error" => "Failed to create temporary chunk directory"];
|
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"];
|
return ["error" => "Upload error on chunk $chunkNumber"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$chunkFile = $tempDir . $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"];
|
return ["error" => "Failed to move uploaded chunk $chunkNumber"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +119,7 @@ class UploadModel {
|
|||||||
fclose($out);
|
fclose($out);
|
||||||
|
|
||||||
// Update metadata.
|
// Update metadata.
|
||||||
$relativeFolder = $folder;
|
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
|
||||||
$metadataKey = ($relativeFolder === '' || strtolower($relativeFolder) === 'root') ? "root" : $relativeFolder;
|
|
||||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
||||||
$metadataFile = META_DIR . $metadataFileName;
|
$metadataFile = META_DIR . $metadataFileName;
|
||||||
$uploadedDate = date(DATE_TIME_FORMAT);
|
$uploadedDate = date(DATE_TIME_FORMAT);
|
||||||
@@ -134,16 +152,16 @@ class UploadModel {
|
|||||||
|
|
||||||
return ["success" => "File uploaded successfully"];
|
return ["success" => "File uploaded successfully"];
|
||||||
} else {
|
} else {
|
||||||
// Handle full upload (non-chunked).
|
// Handle full upload (non-chunked)
|
||||||
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
|
$folderRaw = $post['folder'] ?? 'root';
|
||||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
$folderSan = self::sanitizeFolder((string)$folderRaw);
|
||||||
return ["error" => "Invalid folder name"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseUploadDir = UPLOAD_DIR;
|
$baseUploadDir = UPLOAD_DIR;
|
||||||
if ($folder !== 'root') {
|
if ($folderSan !== '') {
|
||||||
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
|
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
||||||
}
|
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
||||||
return ["error" => "Failed to create upload directory"];
|
return ["error" => "Failed to create upload directory"];
|
||||||
}
|
}
|
||||||
@@ -153,6 +171,10 @@ class UploadModel {
|
|||||||
$metadataChanged = [];
|
$metadataChanged = [];
|
||||||
|
|
||||||
foreach ($files["file"]["name"] as $index => $fileName) {
|
foreach ($files["file"]["name"] as $index => $fileName) {
|
||||||
|
// Basic PHP upload error check per file
|
||||||
|
if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
|
||||||
|
return ["error" => "Error uploading file"];
|
||||||
|
}
|
||||||
$safeFileName = trim(urldecode(basename($fileName)));
|
$safeFileName = trim(urldecode(basename($fileName)));
|
||||||
if (!preg_match($safeFileNamePattern, $safeFileName)) {
|
if (!preg_match($safeFileNamePattern, $safeFileName)) {
|
||||||
return ["error" => "Invalid file name: " . $fileName];
|
return ["error" => "Invalid file name: " . $fileName];
|
||||||
@@ -161,21 +183,22 @@ class UploadModel {
|
|||||||
if (isset($post['relativePath'])) {
|
if (isset($post['relativePath'])) {
|
||||||
$relativePath = is_array($post['relativePath']) ? $post['relativePath'][$index] ?? '' : $post['relativePath'];
|
$relativePath = is_array($post['relativePath']) ? $post['relativePath'][$index] ?? '' : $post['relativePath'];
|
||||||
}
|
}
|
||||||
$uploadDir = $baseUploadDir;
|
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
if (!empty($relativePath)) {
|
if (!empty($relativePath)) {
|
||||||
$subDir = dirname($relativePath);
|
$subDir = dirname($relativePath);
|
||||||
if ($subDir !== '.' && $subDir !== '') {
|
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);
|
$safeFileName = basename($relativePath);
|
||||||
}
|
}
|
||||||
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0775, true)) {
|
if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) {
|
||||||
return ["error" => "Failed to create subfolder"];
|
return ["error" => "Failed to create subfolder: " . $uploadDir];
|
||||||
}
|
}
|
||||||
$targetPath = $uploadDir . $safeFileName;
|
$targetPath = $uploadDir . $safeFileName;
|
||||||
if (move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
|
if (move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
|
||||||
$folderPath = $folder;
|
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
|
||||||
$metadataKey = ($folderPath === '' || strtolower($folderPath) === 'root') ? "root" : $folderPath;
|
|
||||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
||||||
$metadataFile = META_DIR . $metadataFileName;
|
$metadataFile = META_DIR . $metadataFileName;
|
||||||
if (!isset($metadataCollection[$metadataKey])) {
|
if (!isset($metadataCollection[$metadataKey])) {
|
||||||
@@ -208,7 +231,7 @@ class UploadModel {
|
|||||||
}
|
}
|
||||||
return ["success" => "Files uploaded successfully"];
|
return ["success" => "Files uploaded successfully"];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively removes a directory and its contents.
|
* Recursively removes a directory and its contents.
|
||||||
|
|||||||