Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
090286164d | ||
|
|
dc1649ace3 | ||
|
|
b6d86b7896 | ||
|
|
25ce6a76be | ||
|
|
f2ab2a96bc | ||
|
|
c22c8e0f34 | ||
|
|
070515e7a6 | ||
|
|
7a0f4ddbb4 | ||
|
|
e1c15eb95a | ||
|
|
2400dcb9eb | ||
|
|
c717f8be60 | ||
|
|
3dd5a8664a | ||
|
|
0cb47b4054 | ||
|
|
e3e3aaa475 | ||
|
|
494be05801 | ||
|
|
ceb651894e | ||
|
|
ad72ef74d1 | ||
|
|
680c82638f | ||
|
|
31f54afc74 | ||
|
|
4f39b3a41e | ||
|
|
40cecc10ad | ||
|
|
aee78c9750 | ||
|
|
16ccb66d55 | ||
|
|
9209f7a582 | ||
|
|
4a736b0224 | ||
|
|
f162a7d0d7 | ||
|
|
3fc526df7f | ||
|
|
20422cf5a7 | ||
|
|
492bab36ca | ||
|
|
f2f7697994 | ||
|
|
13aa011632 | ||
|
|
1add160f5d | ||
|
|
87368143b5 | ||
|
|
939aa032f0 | ||
|
|
fbd21a035b | ||
|
|
2f391d11db |
@@ -12,3 +12,9 @@ tmp/
|
|||||||
.env
|
.env
|
||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
data/
|
||||||
|
uploads/
|
||||||
|
users/
|
||||||
|
metadata/
|
||||||
|
sessions/
|
||||||
|
vendor/
|
||||||
|
|||||||
92
.github/workflows/ci.yml
vendored
Normal file
92
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
name: CI
|
||||||
|
"on":
|
||||||
|
push:
|
||||||
|
branches: [master, main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
php-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
php: ['8.1', '8.2', '8.3']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php }}
|
||||||
|
coverage: none
|
||||||
|
- name: Validate composer.json (if present)
|
||||||
|
run: |
|
||||||
|
if [ -f composer.json ]; then composer validate --no-check-publish; fi
|
||||||
|
- name: Composer audit (if lock present)
|
||||||
|
run: |
|
||||||
|
if [ -f composer.lock ]; then composer audit || true; fi
|
||||||
|
- name: PHP syntax check
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mapfile -t files < <(git ls-files '*.php')
|
||||||
|
if [ "${#files[@]}" -gt 0 ]; then
|
||||||
|
for f in "${files[@]}"; do php -l "$f"; done
|
||||||
|
else
|
||||||
|
echo "No PHP files found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
shellcheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||||
|
- name: ShellCheck all scripts
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mapfile -t sh < <(git ls-files '*.sh')
|
||||||
|
if [ "${#sh[@]}" -gt 0 ]; then
|
||||||
|
shellcheck "${sh[@]}"
|
||||||
|
else
|
||||||
|
echo "No shell scripts found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
dockerfile-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Lint Dockerfile with hadolint
|
||||||
|
uses: hadolint/hadolint-action@v3.1.0
|
||||||
|
with:
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
failure-threshold: error
|
||||||
|
ignore: DL3008,DL3059
|
||||||
|
|
||||||
|
sanity:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: sudo apt-get update && sudo apt-get install -y jq yamllint
|
||||||
|
- name: Lint JSON
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mapfile -t jsons < <(git ls-files '*.json' ':!:vendor/**')
|
||||||
|
if [ "${#jsons[@]}" -gt 0 ]; then
|
||||||
|
for j in "${jsons[@]}"; do jq -e . "$j" >/dev/null; done
|
||||||
|
else
|
||||||
|
echo "No JSON files."
|
||||||
|
fi
|
||||||
|
- name: Lint YAML
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mapfile -t yamls < <(git ls-files '*.yml' '*.yaml')
|
||||||
|
if [ "${#yamls[@]}" -gt 0 ]; then
|
||||||
|
yamllint -d "{extends: default, rules: {line-length: disable, truthy: {check-keys: false}}}" "${yamls[@]}"
|
||||||
|
else
|
||||||
|
echo "No YAML files."
|
||||||
|
fi
|
||||||
3
.github/workflows/sync-changelog.yml
vendored
3
.github/workflows/sync-changelog.yml
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
---
|
||||||
name: Sync Changelog to Docker Repo
|
name: Sync Changelog to Docker Repo
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -40,4 +41,4 @@ jobs:
|
|||||||
else
|
else
|
||||||
git commit -m "chore: sync CHANGELOG.md from FileRise"
|
git commit -m "chore: sync CHANGELOG.md from FileRise"
|
||||||
git push origin main
|
git push origin main
|
||||||
fi
|
fi
|
||||||
|
|||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/data/
|
||||||
530
CHANGELOG.md
530
CHANGELOG.md
@@ -1,5 +1,527 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 10/17/2025 (v1.5.0)
|
||||||
|
|
||||||
|
Security and permission model overhaul. Tightens access controls with explicit, server‑side ACL checks across controllers and WebDAV. Introduces `read_own` for own‑only visibility and separates view from write so uploaders can’t automatically see others’ files. Fixes session warnings and aligns the admin UI with the new capabilities.
|
||||||
|
|
||||||
|
> **Security note**
|
||||||
|
> This release contains security hardening based on a private report (tracked via a GitHub Security Advisory, CVE pending). For responsible disclosure, details will be published alongside the advisory once available. Users should upgrade promptly.
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- **ACL**
|
||||||
|
- New `read_own` bucket (own‑only visibility) alongside `owners`, `read`, `write`, `share`.
|
||||||
|
- **Semantic change:** `write` no longer implies `read`.
|
||||||
|
- `ACL::applyUserGrantsAtomic()` to atomically set per‑folder grants (`view`, `viewOwn`, `upload`, `manage`, `share`).
|
||||||
|
- `ACL::purgeUser($username)` to remove a user from all buckets (used when deleting a user).
|
||||||
|
- Auto‑heal `folder_acl.json` (ensure `root` exists; add missing buckets; de‑dupe; normalize types).
|
||||||
|
- More robust admin detection (role flag or session/admin user).
|
||||||
|
|
||||||
|
- **Controllers**
|
||||||
|
- `FileController`: ACL + ownership enforcement for list, download, zip download, extract, move, copy, rename, create, save, tag edit, and share‑link creation. `getFileList()` now filters to the caller’s uploads when they only have `read_own` (no `read`).
|
||||||
|
- `UploadController`: requires `ACL::canWrite()` for the target folder; CSRF refresh path improved; admin bypass intact.
|
||||||
|
- `FolderController`: listing filtered by `ACL::canRead()`; optional parent filter preserved; removed name‑based ownership assumptions.
|
||||||
|
|
||||||
|
- **Admin UI**
|
||||||
|
- Folder Access grid now includes **View (own)**; bulk toolbar actions; column alignment fixes; more space for folder names; dark‑mode polish.
|
||||||
|
|
||||||
|
- **WebDAV**
|
||||||
|
- WebDAV now enforces ACL consistently: listing requires `read` (or `read_own` ⇒ shows only caller’s files); writes require `write`.
|
||||||
|
- Removed legacy “folderOnly” behavior — ACL is the single source of truth.
|
||||||
|
- Metadata/uploader is preserved through existing models.
|
||||||
|
|
||||||
|
### Behavior changes (⚠️ Breaking)
|
||||||
|
|
||||||
|
- **`write` no longer implies `read`.**
|
||||||
|
- If you want uploaders to see all files in a folder, also grant **View (all)** (`read`).
|
||||||
|
- If you want uploaders to see only their own files, grant **View (own)** (`read_own`).
|
||||||
|
|
||||||
|
- **Removed:** legacy `folderOnly` view logic in favor of ACL‑based access.
|
||||||
|
|
||||||
|
### Upgrade checklist
|
||||||
|
|
||||||
|
1. Review **Folder Access** in the admin UI and grant **View (all)** or **View (own)** where appropriate.
|
||||||
|
2. For users who previously had “upload but not view,” confirm they now have **Upload** + **View (own)** (or add **View (all)** if intended).
|
||||||
|
3. Verify WebDAV behavior for representative users:
|
||||||
|
- `read` shows full listings; `read_own` lists only the caller’s files.
|
||||||
|
- Writes only succeed where `write` is granted.
|
||||||
|
4. Confirm admin can upload/move/zip across all folders (regression tested).
|
||||||
|
|
||||||
|
### Affected areas
|
||||||
|
|
||||||
|
- `config/config.php` — session/cookie initialization ordering; proxy header handling.
|
||||||
|
- `src/lib/ACL.php` — new bucket, semantics, healing, purge, admin detection.
|
||||||
|
- `src/controllers/FileController.php` — ACL + ownership gates across operations.
|
||||||
|
- `src/controllers/UploadController.php` — write checks + CSRF refresh handling.
|
||||||
|
- `src/controllers/FolderController.php` — ACL‑filtered listing and parent scoping.
|
||||||
|
- `public/api/admin/acl/*.php` — includes `viewOwn` round‑trip and sanitization.
|
||||||
|
- `public/js/*` & CSS — folder access grid alignment and layout fixes.
|
||||||
|
- `src/webdav/*` & `public/webdav.php` — ACL‑aware WebDAV server.
|
||||||
|
|
||||||
|
### Credits
|
||||||
|
|
||||||
|
- Security report acknowledged privately and will be credited in the published advisory.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- fix(folder-model): resolve syntax error, unexpected token
|
||||||
|
- Deleted accidental second `<?php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/15/2025 (v1.4.0)
|
||||||
|
|
||||||
|
feat(permissions)!: granular ACL (bypassOwnership/canShare/canZip/viewOwnOnly), admin panel v1.4.0 UI, and broad hardening across controllers/models/frontend
|
||||||
|
|
||||||
|
### Security / Hardening
|
||||||
|
|
||||||
|
- Tightened ownership checks across file ops; introduced centralized permission helper to avoid falsey-permissions bugs.
|
||||||
|
- Consistent CSRF verification on mutating endpoints; stricter input validation using `REGEX_*` and `basename()` trims.
|
||||||
|
- Safer path handling & metadata reads; reduced noisy error surfaces; consistent HTTP codes (401/403/400/500).
|
||||||
|
- Adds defense-in-depth to reduce risk of unauthorized file manipulation.
|
||||||
|
|
||||||
|
### Config (`config.php`)
|
||||||
|
|
||||||
|
- Add optional defaults for new permissions (all optional):
|
||||||
|
- `DEFAULT_BYPASS_OWNERSHIP` (bool)
|
||||||
|
- `DEFAULT_CAN_SHARE` (bool)
|
||||||
|
- `DEFAULT_CAN_ZIP` (bool)
|
||||||
|
- `DEFAULT_VIEW_OWN_ONLY` (bool)
|
||||||
|
- Keep existing behavior unless explicitly enabled (bypassOwnership typically true for admins; configurable per user).
|
||||||
|
|
||||||
|
### Controllers
|
||||||
|
|
||||||
|
#### `FileController.php`
|
||||||
|
|
||||||
|
- New lightweight `loadPerms($username)` helper that **always** returns an array; prevents type errors when permissions are missing.
|
||||||
|
- Ownership checks now respect: `isAdmin(...) || perms['bypassOwnership'] || DEFAULT_BYPASS_OWNERSHIP`.
|
||||||
|
- Gate sharing/zip operations by `perms['canShare']` / `perms['canZip']`.
|
||||||
|
- Implement `viewOwnOnly` filtering in `getFileList()` (supports both map and list shapes).
|
||||||
|
- Normalize and validate folder/file input; enforce folder-only scope for writes/moves/copies where applicable.
|
||||||
|
- Improve error handling: convert warnings/notices to exceptions within try/catch; consistent JSON error payloads.
|
||||||
|
- Add missing `require_once PROJECT_ROOT . '/src/models/UserModel.php'` to fix “Class userModel not found”.
|
||||||
|
- Download behavior: inline for images, attachment for others; owner/bypass logic applied.
|
||||||
|
|
||||||
|
#### `FolderController.php`
|
||||||
|
|
||||||
|
- `createShareFolderLink()` gated by `canShare`; validates duration (cap at 1y), folder names, password optional.
|
||||||
|
- (If present) folder share deletion/read endpoints wired to new permission model.
|
||||||
|
|
||||||
|
#### `AdminController.php`
|
||||||
|
|
||||||
|
- `getConfig()` remains admin-only; returns safe subset. (Non-admins now simply receive 403; client can ignore.)
|
||||||
|
|
||||||
|
#### `UserController.php`
|
||||||
|
|
||||||
|
- Plumbs new permission fields in get/set endpoints (`folderOnly`, `readOnly`, `disableUpload`, **`bypassOwnership`**, **`canShare`**, **`canZip`**, **`viewOwnOnly`**).
|
||||||
|
- Normalizes username keys and defaults to prevent undefined-index errors.
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
#### `FileModel.php` / `FolderModel.php`
|
||||||
|
|
||||||
|
- Respect caller’s effective permissions (controllers pass-through); stricter input normalization.
|
||||||
|
- ZIP creation/extraction guarded via `canZip`; metadata updates consistent; safer temp paths.
|
||||||
|
- Improved return shapes and error messages (never return non-array on success paths).
|
||||||
|
|
||||||
|
#### `AdminModel.php`
|
||||||
|
|
||||||
|
- Reads/writes admin config with new `loginOptions` intact; never exposes sensitive OIDC secrets to the client layer.
|
||||||
|
|
||||||
|
#### `UserModel.php`
|
||||||
|
|
||||||
|
- Store/load the 4 new flags; helper ensures absent users/fields don’t break caller; returns normalized arrays.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
#### `main.js`
|
||||||
|
|
||||||
|
- Initialize after CSRF; keep dark-mode persistence, welcome toast, drag-over UX.
|
||||||
|
- Leaves `loadAdminConfigFunc()` call in place (non-admins may 403; harmless).
|
||||||
|
|
||||||
|
#### `adminPanel.js` (v1.4.0)
|
||||||
|
|
||||||
|
- New **User Permissions** UI with collapsible rows per user:
|
||||||
|
- Shows username; clicking expands a checkbox matrix.
|
||||||
|
- Permissions: `folderOnly`, `readOnly`, `disableUpload`, **`bypassOwnership`**, **`canShare`**, **`canZip`**, **`viewOwnOnly`**.
|
||||||
|
- **Manage Shared Links** section reads folder & file share metadata; delete buttons per token.
|
||||||
|
- Refined modal sizing & dark-mode styling; consistent toasts; unsaved-changes confirmation.
|
||||||
|
- Keeps 403 from `/api/admin/getConfig.php` for non-admins (acceptable; no UI break).
|
||||||
|
|
||||||
|
### Breaking change
|
||||||
|
|
||||||
|
- Non-admin users without `bypassOwnership` can no longer create/rename/move/copy/delete/share/zip files they don’t own.
|
||||||
|
- If legacy behavior depended on broad access, set `bypassOwnership` per user or use `DEFAULT_BYPASS_OWNERSHIP=true` in `config.php`.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- Add the new flags to existing users in your permissions store (or rely on `config.php` defaults).
|
||||||
|
- Verify admin accounts have either `isAdmin` or `bypassOwnership`/`canShare`/`canZip` as desired.
|
||||||
|
- Optionally tune `DEFAULT_*` constants for instance-wide defaults.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Hardened access controls for file operations based on an external security report.
|
||||||
|
Details are withheld temporarily to protect users; a full advisory will follow after wider adoption of the fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/8/2025 (no new version)
|
||||||
|
|
||||||
|
chore: set up CI, add compose, tighten ignores, refresh README
|
||||||
|
|
||||||
|
- CI: add workflow to lint PHP (php -l), validate/audit composer,
|
||||||
|
shellcheck *.sh, hadolint Dockerfile, and sanity-check JSON/YAML; supports
|
||||||
|
push/PR/manual dispatch.
|
||||||
|
- Docker: add docker-compose.yml for local dev (8080:80, volumes/env).
|
||||||
|
- .dockerignore: exclude VCS, build artifacts, OS/editor junk, logs, temp dirs,
|
||||||
|
node_modules, resources/, etc. to slim build context.
|
||||||
|
- .gitignore: ignore .env, editor/system files, build caches, optional data/.
|
||||||
|
- README: update badges (CI, release, license), inline demo creds, add quick
|
||||||
|
links, tighten WebDAV section (Windows HTTPS note + wiki link), reduced length and star
|
||||||
|
history chart.
|
||||||
|
|
||||||
|
## Changes 10/7/2025 (no new version)
|
||||||
|
|
||||||
|
feat(startup): stream error.log to console by default; add LOG_STREAM selector
|
||||||
|
|
||||||
|
- Touch error/access logs on start so tail can attach immediately
|
||||||
|
- Add LOG_STREAM=error|access|both|none (default: error)
|
||||||
|
- Tail with `-n0 -F` to follow new entries only and survive rotations
|
||||||
|
- Keep access.log on disk but don’t spam console unless requested
|
||||||
|
- (Unraid) Optional env var template entry for LOG_STREAM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/6/2025 v1.3.15
|
||||||
|
|
||||||
|
feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48)
|
||||||
|
|
||||||
|
- fileEditor.js: block ≥10 MB; plain-text fallback >5 MB; lighter CM settings for big files.
|
||||||
|
- fileListView.js: latest-call-wins; compute editable via ext + sizeBytes (no blink).
|
||||||
|
- FileModel.php: add sizeBytes; cap inline content to ≤5 MB (INDEX_TEXT_BYTES_MAX).
|
||||||
|
- HTML: load extra CM modes: htmlmixed, php, clike, python, yaml, markdown, shell, sql, vb, ruby, perl, properties, nginx.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/5/2025 v1.3.14
|
||||||
|
|
||||||
|
fix(admin): OIDC optional by default; validate only when enabled (fixes #44)
|
||||||
|
|
||||||
|
- AdminModel::updateConfig now enforces OIDC fields only if disableOIDCLogin=false
|
||||||
|
- AdminModel::getConfig defaults disableOIDCLogin=true and guarantees OIDC keys
|
||||||
|
- AdminController default loginOptions sets disableOIDCLogin=true; CSRF via header or body
|
||||||
|
- Normalize file perms to 0664 after write
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/4/2025 v1.3.13
|
||||||
|
|
||||||
|
fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash
|
||||||
|
fix(scanner): rebuild per-folder metadata to match File/Folder models
|
||||||
|
chore(scanner): skip profile_pics subtree during scans
|
||||||
|
|
||||||
|
- scan_uploads.php now falls back to UPLOAD_DIR/META_DIR from config.php
|
||||||
|
- prevents double slashes in metadata paths; respects app timezone
|
||||||
|
- unblocks SCAN_ON_START so externally added files are indexed at boot
|
||||||
|
- Writes per-folder metadata files (root_metadata.json / folder_metadata.json) using the same naming rule as the models
|
||||||
|
- Adds missing entries for files (uploaded, modified using DATE_TIME_FORMAT, uploader=Imported)
|
||||||
|
- Prunes stale entries for files that no longer exist
|
||||||
|
- Skips uploads/trash and symlinks
|
||||||
|
- Resolves paths from CLI flags, env vars, or config constants (UPLOAD_DIR/META_DIR)
|
||||||
|
- Idempotent; safe to run at startup via SCAN_ON_START
|
||||||
|
- Avoids indexing internal avatar images (folder already hidden in UI)
|
||||||
|
- Reduces scan noise and metadata churn; keeps firmware/other content indexed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/4/2025 v1.3.12
|
||||||
|
|
||||||
|
Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
|
||||||
|
|
||||||
|
- Remap www-data to PUID/PGID when running as root; skip with helpful log if non-root
|
||||||
|
- Added CHOWN_ON_START env to control recursive chown (default true; turn off after first run)
|
||||||
|
- SCAN_ON_START unchanged, with non-root fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/4/2025 v1.3.11
|
||||||
|
|
||||||
|
Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
|
||||||
|
|
||||||
|
- Remove no-op sed of SHARE_URL from start.sh (env already used)
|
||||||
|
- Build default share link with correct scheme (http/https, proxy-aware)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/4/2025 v1.3.10
|
||||||
|
|
||||||
|
Fix: index externally added files on startup; harden start.sh (#46)
|
||||||
|
|
||||||
|
- Run metadata scan before Apache when SCAN_ON_START=true (was unreachable after exec)
|
||||||
|
- Execute scan as www-data; continue on failure so startup isn’t blocked
|
||||||
|
- Guard env reads for set -u; add umask 002 for consistent 775/664
|
||||||
|
- Make ServerName idempotent; avoid duplicate entries
|
||||||
|
- Ensure sessions/metadata/log dirs exist with correct ownership and perms
|
||||||
|
|
||||||
|
No behavior change unless SCAN_ON_START=true.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/27/2025 v1.3.9
|
||||||
|
|
||||||
|
- Support for mounting CIFS (SMB) network shares via Docker volumes
|
||||||
|
- New `scripts/scan_uploads.php` script to generate metadata for imported files and folders
|
||||||
|
- `SCAN_ON_START` environment variable to trigger automatic scanning on container startup
|
||||||
|
- Documentation for configuring CIFS share mounting and scanning
|
||||||
|
|
||||||
|
- Clipboard Paste Upload Support (single image):
|
||||||
|
- Users can now paste images directly into the FileRise web interface.
|
||||||
|
- Pasted images are renamed to `image<TIMESTAMP>.png` and added to the upload queue using the existing drag-and-drop logic.
|
||||||
|
- Implemented using a `.isClipboard` flag and a delayed UI cleanup inside `xhr.addEventListener("load", ...)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/26/2025
|
||||||
|
|
||||||
|
- Updated `REGEX_FOLDER_NAME` in `config.php` to forbids < > : " | ? * characters in folder names.
|
||||||
|
- Ensures the whole name can’t end in a space or period.
|
||||||
|
- Blocks Windows device names.
|
||||||
|
|
||||||
|
- Updated `FolderController.php` when `createFolder` issues invalid folder name to return `http_response_code(400);`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/23/2025 v1.3.8
|
||||||
|
|
||||||
|
- **Folder-strip context menu**
|
||||||
|
- Enabled right-click on items in the new folder strip (above file list) to open the same “Create / Rename / Share / Delete Folder” menu as in the main folder tree.
|
||||||
|
- Bound `contextmenu` event on each `.folder-item` in `loadFileList` to:
|
||||||
|
- Prevent the default browser menu
|
||||||
|
- Highlight the clicked folder-strip item
|
||||||
|
- Invoke `showFolderManagerContextMenu` with menu entries:
|
||||||
|
- Create Folder
|
||||||
|
- Rename Folder
|
||||||
|
- Share Folder (passes the strip’s `data-folder` value)
|
||||||
|
- Delete Folder
|
||||||
|
- Ensured menu actions are wrapped in arrow functions (`() => …`) so they fire only on menu-item click, not on render.
|
||||||
|
|
||||||
|
- Refactored folder-strip injection in `fileListView.js` to:
|
||||||
|
- Mark each strip item as `draggable="true"` (for drag-and-drop)
|
||||||
|
- Add `el.addEventListener("contextmenu", …)` alongside existing click/drag handlers
|
||||||
|
- Clean up global click listener for hiding the context menu
|
||||||
|
|
||||||
|
- Prevented premature invocation of `openFolderShareModal` by switching to `action: () => openFolderShareModal(dest)` instead of calling it directly.
|
||||||
|
|
||||||
|
- **Create File/Folder dropdown**
|
||||||
|
- Replaced standalone “Create File” button with a combined dropdown button in the actions toolbar.
|
||||||
|
- New markup
|
||||||
|
- Wired up JS handlers in `fileActions.js`:
|
||||||
|
- `#createFileOption` → `openCreateFileModal()`
|
||||||
|
- `#createFolderOption` → `document.getElementById('createFolderModal').style.display = 'block'`
|
||||||
|
- Toggled `.dropdown-menu` visibility on button click, and closed on outside click.
|
||||||
|
- Applied dark-mode support: dropdown background and text colors switch with `.dark-mode` class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/22/2025 v1.3.7
|
||||||
|
|
||||||
|
- `.folder-strip-container .folder-name` css added to center text below folder material icon.
|
||||||
|
- Override file share_url to always use current origin
|
||||||
|
- Update `fileList` css to keep file name wrapping tight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/21/2025
|
||||||
|
|
||||||
|
- **Drag & Drop to Folder Strip**
|
||||||
|
- Enabled dragging files from the file list directly onto the folder-strip items.
|
||||||
|
- Hooked up `folderDragOverHandler`, `folderDragLeaveHandler`, and `folderDropHandler` to `.folder-strip-container .folder-item`.
|
||||||
|
- On drop, files are moved via `/api/file/moveFiles.php` and the file list is refreshed.
|
||||||
|
|
||||||
|
- **Restore files from trash Toast Message**
|
||||||
|
- Changed the restore handlers so that the toast always reports the actual file(s) restored (e.g. “Restored file: foo.txt”) instead of “No trash record found.”
|
||||||
|
- Removed reliance on backend message payload and now generate the confirmation text client-side based on selected items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/20/2025 v1.3.6
|
||||||
|
|
||||||
|
- **domUtils.js**
|
||||||
|
- `updateFileActionButtons`
|
||||||
|
- Hide selection buttons (`Delete Files`, `Copy Files`, `Move Files` & `Download ZIP`) until file is selected.
|
||||||
|
- Hide `Extract ZIP` until selecting zip files
|
||||||
|
- Hide `Create File` button when file list items are selected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/19/2025 v1.3.5
|
||||||
|
|
||||||
|
### Added Folder strip & Create File
|
||||||
|
|
||||||
|
- **Folder strip in file list**
|
||||||
|
- `loadFileList` now fetches sub-folders in parallel from `/api/folder/getFolderList.php`.
|
||||||
|
- Filters to only direct children of the current folder, hiding `profile_pics` and `trash`.
|
||||||
|
- Injects a new `.folder-strip-container` just below the Files In above (summary + slider).
|
||||||
|
- Clicking a folder in the strip updates:
|
||||||
|
- the breadcrumb (via `updateBreadcrumbTitle`)
|
||||||
|
- the tree selection highlight
|
||||||
|
- reloads `loadFileList` for the chosen folder.
|
||||||
|
|
||||||
|
- **Create File feature**
|
||||||
|
- New “Create New File” button added to the file-actions toolbar and context menu.
|
||||||
|
- New endpoint `public/api/file/createFile.php` (handled by `FileController`/`FileModel`):
|
||||||
|
- Creates an empty file if it doesn’t already exist.
|
||||||
|
- Appends an entry to `<folder>_metadata.json` with `uploaded` timestamp and `uploader`.
|
||||||
|
- `fileActions.js`:
|
||||||
|
- Implemented `handleCreateFile()` to show a modal, POST to the new endpoint, and refresh the list.
|
||||||
|
- Added translations for `create_new_file` and `newfile_placeholder`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changees 5/15/2025
|
||||||
|
|
||||||
|
### Drag‐and‐Drop Upload extended to File List
|
||||||
|
|
||||||
|
- **Forward file‐list drops**
|
||||||
|
Dropping files onto the file‐list area (`#fileListContainer`) now re‐dispatches the same `drop` event to the upload card’s drop zone (`#uploadDropArea`)
|
||||||
|
- **Visual feedback**
|
||||||
|
Added a `.drop-hover` class on `#fileListContainer` during drag‐over for a dashed‐border + light‐background hover state to indicate it accepts file drops.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/14/2025 v1.3.4
|
||||||
|
|
||||||
|
### 1. Button Grouping (Bootstrap)
|
||||||
|
|
||||||
|
- Converted individual action buttons (`download`, `edit`, `rename`, `share`) in both **table view** and **gallery view** into a single Bootstrap button group for a cleaner, more compact UI.
|
||||||
|
- Applied `btn-group` and `btn-sm` classes for consistent sizing and spacing.
|
||||||
|
|
||||||
|
### 2. Header Dropdown Replacement
|
||||||
|
|
||||||
|
- Replaced the standalone “User Panel” icon button with a **dropdown wrapper** (`.user-dropdown`) in the header.
|
||||||
|
- Dropdown toggle now shows:
|
||||||
|
- **Profile picture** (if set) or the Material “account_circle” icon
|
||||||
|
- **Username** text (between avatar and caret)
|
||||||
|
- Down-arrow caret span.
|
||||||
|
|
||||||
|
### 3. Menu Items Moved to Dropdown
|
||||||
|
|
||||||
|
- Moved previously standalone header buttons into the dropdown menu:
|
||||||
|
- **User Panel** opens the modal
|
||||||
|
- **Admin Panel** only shown when `data.isAdmin` and on `demo.filerise.net`
|
||||||
|
- **API Docs** calls `openApiModal()`
|
||||||
|
- **Logout** calls `triggerLogout()`
|
||||||
|
- Each menu item now has a matching Material icon (e.g. `person`, `admin_panel_settings`, `description`, `logout`).
|
||||||
|
|
||||||
|
### 4. Profile Picture Support
|
||||||
|
|
||||||
|
- Added a new `/api/profile/uploadPicture.php` endpoint + `UserController::uploadPicture()` + corresponding `UserModel::setProfilePicture()`.
|
||||||
|
- On **Open User Panel**, display:
|
||||||
|
- Default avatar if none set
|
||||||
|
- Current profile picture if available
|
||||||
|
- In the **User Panel** modal:
|
||||||
|
- Stylish “edit” overlay icon on the avatar to launch file picker
|
||||||
|
- Auto-upload on file selection (no “Save” button click needed)
|
||||||
|
- Preview updates immediately and header avatar refreshes live
|
||||||
|
- Persisted in `users.txt` and re-fetched via `getCurrentUser.php`
|
||||||
|
|
||||||
|
### 5. API Docs & Logout Relocation
|
||||||
|
|
||||||
|
- Removed API Docs from User Panel
|
||||||
|
- Removed “Logout” buttons from the header toolbar.
|
||||||
|
- Both are now menu entries in the **User Dropdown**.
|
||||||
|
|
||||||
|
### 6. Admin Panel Conditional
|
||||||
|
|
||||||
|
- The **Admin Panel** button was:
|
||||||
|
- Kept in the dropdown only when `data.isAdmin`
|
||||||
|
- Removed entirely elsewhere.
|
||||||
|
|
||||||
|
### 7. Utility & Styling Tweaks
|
||||||
|
|
||||||
|
- Introduced a small `normalizePicUrl()` helper to strip stray colons and ensure a leading slash.
|
||||||
|
- Hidden the scrollbar in the User Panel modal via:
|
||||||
|
- Inline CSS (`scrollbar-width: none; -ms-overflow-style: none;`)
|
||||||
|
- Global/WebKit rule for `::-webkit-scrollbar { display: none; }`
|
||||||
|
- Made the User Panel modal fully responsive and vertically centered, with smooth dark-mode support.
|
||||||
|
|
||||||
|
### 8. File/List View & Gallery View Sliders
|
||||||
|
|
||||||
|
- **Unified “View‐Mode” Slider**
|
||||||
|
Added a single slider panel (`#viewSliderContainer`) in the file‐list actions toolbar that switches behavior based on the current view mode:
|
||||||
|
- **Table View**: shows a **Row Height** slider (min 31px, max 60px).
|
||||||
|
- Adjusts the CSS variable `--file-row-height` to resize all `<tr>` heights.
|
||||||
|
- Persists the chosen height in `localStorage`.
|
||||||
|
- **Gallery View**: shows a **Columns** slider (min 1, max 6).
|
||||||
|
- Updates the grid’s `grid-template-columns: repeat(N, 1fr)`.
|
||||||
|
- Persists the chosen column count in `localStorage`.
|
||||||
|
|
||||||
|
- **Injection Point**
|
||||||
|
The slider container is dynamically inserted (or updated) just before the folder summary (`#fileSummary`) in `loadFileList()`, ensuring a consistent position across both view modes.
|
||||||
|
|
||||||
|
- **Live Updates**
|
||||||
|
Moving the slider thumb immediately updates the visible table row heights or gallery column layout without a full re‐render.
|
||||||
|
|
||||||
|
- **Styling & Alignment**
|
||||||
|
- `#viewSliderContainer` uses `inline-flex` and `align-items: center` so that label, slider, and value text are vertically aligned with the other toolbar elements.
|
||||||
|
- Reset margins/padding on the label and value span within `#viewSliderContainer` to eliminate any vertical misalignment.
|
||||||
|
|
||||||
|
### 9. Fixed new issues with Undefined username in header on profile pic change & TOTP Enabled not checked
|
||||||
|
|
||||||
|
**openUserPanel**
|
||||||
|
|
||||||
|
- **Rewritten entirely with DOM APIs** instead of `innerHTML` for any user-supplied text to eliminates “DOM text reinterpreted as HTML” warnings.
|
||||||
|
- **Default avatar fallback**: now uses `'/assets/default-avatar.png'` whenever `profile_picture` is empty.
|
||||||
|
- **TOTP checkbox initial state** is now set from the `totp_enabled` value returned by the server.
|
||||||
|
- **Modal title sync** on reopen now updates the `(username)` correctly (no more “undefined” until refresh).
|
||||||
|
- **Re-sync on reopen**: background color, avatar, TOTP checkbox and language selector all update when reopen the panel.
|
||||||
|
|
||||||
|
**updateAuthenticatedUI**
|
||||||
|
|
||||||
|
- **Username fix**: dropdown toggle now always uses `data.username` so the name never becomes `undefined` after uploading a picture.
|
||||||
|
- **Profile URL update** via `fetchProfilePicture()` always writes into `localStorage` before rebuilding the header, ensuring avatar+name stay in sync instantly.
|
||||||
|
- **Dropdown rebuild logic** tweaked to update the toggle’s innerHTML with both avatar and username on every call.
|
||||||
|
|
||||||
|
**UserModel::getUser**
|
||||||
|
|
||||||
|
- Switched to `explode(':', $line, 4)` to the fourth “profile_picture” field without clobbering the TOTP secret.
|
||||||
|
- **Strip trailing colons** from the stored URL (`rtrim($parts[3], ':')`) so we never send `…png:` back to the client.
|
||||||
|
- Returns an array with both `'username'` and `'profile_picture'`, matching what `getCurrentUser.php` needs.
|
||||||
|
|
||||||
|
### 10. setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts
|
||||||
|
|
||||||
|
### 11. Fix duplicated Upload & Folder cards if they were added to header and page was refreshed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/8/2025
|
||||||
|
|
||||||
|
### Docker 🐳
|
||||||
|
|
||||||
|
- Ensure `/var/www/config` exists and is owned by `www-data` (chmod 750) so that `start.sh`’s `sed -i` updates to `config.php` work reliably
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 5/8/2025 v1.3.3
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
- **Admin API** (`updateConfig.php`):
|
||||||
|
- Now merges incoming payload onto existing on-disk settings instead of overwriting blanks.
|
||||||
|
- Preserves `clientId`, `clientSecret`, `providerUrl` and `redirectUri` when those fields are omitted or empty in the request.
|
||||||
|
|
||||||
|
- **Admin API** (`getConfig.php`):
|
||||||
|
- Returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
|
||||||
|
|
||||||
|
- **Frontend** (`auth.js`):
|
||||||
|
- Update UI based on merged loginOptions from the server, ensuring blank or missing fields no longer revert your existing config.
|
||||||
|
|
||||||
|
- **Auth API** (`auth.php`):
|
||||||
|
- Added `$oidc->addScope(['openid','profile','email']);` to OIDC flow. (This should resolve authentik issue)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 5/8/2025 v1.3.2
|
## Changes 5/8/2025 v1.3.2
|
||||||
|
|
||||||
### config/config.php
|
### config/config.php
|
||||||
@@ -8,7 +530,7 @@
|
|||||||
- Removed the static `AUTH_HEADER` fallback; instead read the adminConfig.json at the end of the file and:
|
- Removed the static `AUTH_HEADER` fallback; instead read the adminConfig.json at the end of the file and:
|
||||||
- Overwrote `AUTH_BYPASS` with the `loginOptions.authBypass` setting from disk.
|
- Overwrote `AUTH_BYPASS` with the `loginOptions.authBypass` setting from disk.
|
||||||
- Defined `AUTH_HEADER` (normalized, e.g. `"X_REMOTE_USER"`) based on `loginOptions.authHeaderName`.
|
- Defined `AUTH_HEADER` (normalized, e.g. `"X_REMOTE_USER"`) based on `loginOptions.authHeaderName`.
|
||||||
- Inserted a **proxy-only auto-login** block *before* the usual session/auth checks:
|
- Inserted a **proxy-only auto-login** block before the usual session/auth checks:
|
||||||
If `AUTH_BYPASS` is true and the trusted header (`$_SERVER['HTTP_' . AUTH_HEADER]`) is present, bump the session, mark the user authenticated/admin, load their permissions, and skip straight to JSON output.
|
If `AUTH_BYPASS` is true and the trusted header (`$_SERVER['HTTP_' . AUTH_HEADER]`) is present, bump the session, mark the user authenticated/admin, load their permissions, and skip straight to JSON output.
|
||||||
- Relax filename validation regex to allow broader Unicode and special chars
|
- Relax filename validation regex to allow broader Unicode and special chars
|
||||||
|
|
||||||
@@ -50,6 +572,10 @@
|
|||||||
- In the “not authenticated” branch, only shows the login form if `authBypass` is false.
|
- In the “not authenticated” branch, only shows the login form if `authBypass` is false.
|
||||||
- No other core fetch/token logic changed; all existing flows remain intact.
|
- No other core fetch/token logic changed; all existing flows remain intact.
|
||||||
|
|
||||||
|
### Security old
|
||||||
|
|
||||||
|
- **Admin API**: `getConfig.php` now returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Changes 5/4/2025 v1.3.1
|
## Changes 5/4/2025 v1.3.1
|
||||||
@@ -75,7 +601,7 @@
|
|||||||
|
|
||||||
- **Added** `addUserModal`, `removeUserModal` & `renameFileModal` modals to `style="display:none;"`
|
- **Added** `addUserModal`, `removeUserModal` & `renameFileModal` modals to `style="display:none;"`
|
||||||
|
|
||||||
### `main.js`
|
**`main.js`**
|
||||||
|
|
||||||
- **Extracted** `initializeApp()` helper to centralize post-auth startup (tag search, file list, drag-and-drop, folder tree, upload, trash/restore, admin config).
|
- **Extracted** `initializeApp()` helper to centralize post-auth startup (tag search, file list, drag-and-drop, folder tree, upload, trash/restore, admin config).
|
||||||
- **Updated** DOMContentLoaded `checkAuthentication()` flow to call `initializeApp()` when already authenticated.
|
- **Updated** DOMContentLoaded `checkAuthentication()` flow to call `initializeApp()` when already authenticated.
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini
|
|||||||
COPY --from=appsource /var/www /var/www
|
COPY --from=appsource /var/www /var/www
|
||||||
COPY --from=composer /app/vendor /var/www/vendor
|
COPY --from=composer /app/vendor /var/www/vendor
|
||||||
|
|
||||||
|
# ── ensure config/ is writable by www-data so sed -i can work ──
|
||||||
|
RUN mkdir -p /var/www/config \
|
||||||
|
&& chown -R www-data:www-data /var/www/config \
|
||||||
|
&& chmod 750 /var/www/config
|
||||||
|
|
||||||
# Secure permissions: code read-only, only data dirs writable
|
# Secure permissions: code read-only, only data dirs writable
|
||||||
RUN chown -R root:www-data /var/www && \
|
RUN chown -R root:www-data /var/www && \
|
||||||
find /var/www -type d -exec chmod 755 {} \; && \
|
find /var/www -type d -exec chmod 755 {} \; && \
|
||||||
|
|||||||
255
README.md
255
README.md
@@ -1,5 +1,14 @@
|
|||||||
# FileRise
|
# FileRise
|
||||||
|
|
||||||
|
[](https://github.com/error311/FileRise)
|
||||||
|
[](https://hub.docker.com/r/error311/filerise-docker)
|
||||||
|
[](https://github.com/error311/FileRise/actions/workflows/ci.yml)
|
||||||
|
[](https://demo.filerise.net) **demo / demo**
|
||||||
|
[](https://github.com/error311/FileRise/releases)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting)
|
||||||
|
|
||||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
**Elevate your File Management** – A modern, self-hosted web file manager.
|
||||||
Upload, organize, and share files or folders through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
|
Upload, organize, and share files or folders through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
|
||||||
|
|
||||||
@@ -14,76 +23,102 @@ Upload, organize, and share files or folders through a sleek web interface. **Fi
|
|||||||
|
|
||||||
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
|
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||||
|
|
||||||
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with pause/resumable chunked uploads and shows real-time progress for each file. No more failed transfers – FileRise will pick up where it left off if your connection drops.
|
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with pause/resumable chunked uploads and shows real-time progress for each file. FileRise will pick up where it left off if your connection drops.
|
||||||
|
|
||||||
- 🗂️ **File Management:** Full set of file/folder operations – move or copy files (via intuitive drag-drop or dialogs), rename items, and delete in batches. You can even download selected files as a ZIP archive or extract uploaded ZIP files server-side. Organize content with an interactive folder tree and breadcrumb navigation for quick jumps.
|
- 🗂️ **File Management:** Full set of file/folder operations – move or copy files (via intuitive drag-drop or dialogs), rename items, and delete in batches. You can download selected files as a ZIP archive or extract uploaded ZIP files server-side. Organize content with an interactive folder tree and breadcrumb navigation for quick jumps.
|
||||||
|
|
||||||
- 🗃️ **Folder Sharing & File Sharing:** Easily share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items per page) with navigation controls, and file sizes are displayed in MB for clarity. Share files with others using one-time or expiring public links (with password protection if desired) – convenient for sending individual files without exposing the whole app.
|
- 🗃️ **Folder Sharing & File Sharing:** Share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items per page) with navigation controls; file sizes are displayed in MB for clarity. Share individual files with one-time or expiring links (optional password protection).
|
||||||
|
|
||||||
- 🔌 **WebDAV Support:** Mount FileRise as a network drive **or use it head‑less from the CLI**. Standard WebDAV operations (upload / download / rename / delete) work in Cyberduck, WinSCP, GNOME Files, Finder, etc., and you can also script against it with `curl` – see the [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV) + [curl](https://github.com/error311/FileRise/wiki/Accessing-FileRise-via-curl%C2%A0(WebDAV)) quick‑start for examples. Folder‑Only users are restricted to their personal directory, while admins and unrestricted users have full access.
|
- 🔌 **WebDAV Support:** Mount FileRise as a network drive **or use it head-less from the CLI**. Standard WebDAV operations (upload / download / rename / delete) work in Cyberduck, WinSCP, GNOME Files, Finder, etc., and you can also script against it with `curl` – see the [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV) + [curl](https://github.com/error311/FileRise/wiki/Accessing-FileRise-via-curl-(WebDAV)) quick-starts. Folder-Only users are restricted to their personal directory; admins and unrestricted users have full access.
|
||||||
|
|
||||||
- 📚 **API Documentation:** Fully auto‑generated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
|
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
|
||||||
|
|
||||||
- 📝 **Built-in Editor & Preview:** View images, videos, audio, and PDFs inline with a preview modal – no need to download just to see them. Edit text/code files right in your browser with a CodeMirror-based editor featuring syntax highlighting and line numbers. Great for config files or notes – tweak and save changes without leaving FileRise.
|
- 📝 **Built-in Editor & Preview:** View images, videos, audio, and PDFs inline with a preview modal. Edit text/code files in your browser with a CodeMirror-based editor featuring syntax highlighting and line numbers.
|
||||||
|
|
||||||
- 🏷️ **Tags & Search:** Categorize your files with color-coded tags and locate them instantly using our indexed real-time search. Easily switch to Advanced Search mode to enable fuzzy matching not only across file names, tags, and uploader fields but also within the content of text files—helping you find that “important” document even if you make a typo or need to search deep within the file.
|
- 🏷️ **Tags & Search:** Categorize your files with color-coded tags and locate them instantly using indexed real-time search. **Advanced Search** adds fuzzy matching across file names, tags, uploader fields, and within text file contents.
|
||||||
|
|
||||||
- 🔒 **User Authentication & User Permissions:** Secure your portal with username/password login. Supports multiple users – create user accounts (admin UI provided) for family or team members. User permissions such as User “Folder Only” feature assigns each user a dedicated folder within the root directory, named after their username, restricting them from viewing or modifying other directories. User Read Only and Disable Upload are additional permissions. FileRise also integrates with Single Sign-On (OIDC) providers (e.g., OAuth2/OIDC for Google/Authentik/Keycloak) and offers optional TOTP two-factor auth for extra security.
|
- 🔒 **User Authentication & Permissions:** Username/password login with multi-user support (admin UI). Current permissions: **Folder-only**, **Read-only**, **Disable upload**. SSO via OIDC providers (Google/Authentik/Keycloak) and optional TOTP 2FA.
|
||||||
|
|
||||||
- 🎨 **Responsive UI (Dark/Light Mode):** FileRise is mobile-friendly out of the box – manage files from your phone or tablet with a responsive layout. Choose between Dark mode or Light theme, or let it follow your system preference. The interface remembers your preferences (layout, items per page, last visited folder, etc.) for a personalized experience each time.
|
- 🎨 **Responsive UI (Dark/Light Mode):** Mobile-friendly layout with theme toggle. The interface remembers your preferences (layout, items per page, last visited folder, etc.).
|
||||||
|
|
||||||
- 🌐 **Internationalization & Localization:** FileRise supports multiple languages via an integrated i18n system. Users can switch languages through a user panel dropdown, and their choice is saved in local storage for a consistent experience across sessions. Currently available in English, Spanish, French & German—please report any translation issues you encounter.
|
- 🌐 **Internationalization & Localization:** Switch languages via the UI (English, Spanish, French, German). Contributions welcome.
|
||||||
|
|
||||||
- 🗑️ **Trash & File Recovery:** Mistakenly deleted files? No worries – deleted items go to the Trash instead of immediate removal. Admins can restore files from Trash or empty it to free space. FileRise auto-purges old trash entries (default 3 days) to keep your storage tidy.
|
- 🗑️ **Trash & File Recovery:** Deleted items go to Trash first; admins can restore or empty. Old trash entries auto-purge (default 3 days).
|
||||||
|
|
||||||
- ⚙️ **Lightweight & Self‑Contained:** FileRise runs on PHP 8.1+ with no external database required – data is stored in files (users, metadata) for simplicity. It’s a single‑folder web app you can drop into any Apache/PHP server or run as a container. Docker & Unraid ready: use our pre‑built image for a hassle‑free setup. Memory and CPU footprint is minimal, yet the app scales to thousands of files with pagination and sorting features.
|
- ⚙️ **Lightweight & Self-Contained:** Runs on PHP **8.3+** with no external database. Single-folder install or Docker image. Low footprint; scales to thousands of files with pagination and sorting.
|
||||||
|
|
||||||
(For a full list of features and detailed changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [changelog](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or the [releases](https://github.com/error311/FileRise/releases) pages.)
|
(For full features and changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [CHANGELOG](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or [Releases](https://github.com/error311/FileRise/releases).)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Live Demo
|
## Live Demo
|
||||||
|
|
||||||
|
[](https://demo.filerise.net)
|
||||||
|
**Demo credentials:** `demo` / `demo`
|
||||||
|
|
||||||
Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (login with username “demo” and password “demo”). *The demo is read-only for security*. Explore the interface, switch themes, preview files, and see FileRise in action!
|
Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (login with username “demo” and password “demo”). *The demo is read-only for security*. Explore the interface, switch themes, preview files, and see FileRise in action!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation & Setup
|
## Installation & Setup
|
||||||
|
|
||||||
You can deploy FileRise either by running the **Docker container** (quickest way) or by a **manual installation** on a PHP web server. Both methods are outlined below.
|
Deploy FileRise using the **Docker image** (quickest) or a **manual install** on a PHP web server.
|
||||||
|
|
||||||
### 1. Running with Docker (Recommended)
|
---
|
||||||
|
|
||||||
If you have Docker installed, you can get FileRise up and running in minutes:
|
### 1) Running with Docker (Recommended)
|
||||||
|
|
||||||
- **Pull the image from Docker Hub:**
|
#### Pull the image
|
||||||
|
|
||||||
``` bash
|
```bash
|
||||||
docker pull error311/filerise-docker:latest
|
docker pull error311/filerise-docker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Run a container:**
|
#### Run a container
|
||||||
|
|
||||||
``` bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
|
--name filerise \
|
||||||
-p 8080:80 \
|
-p 8080:80 \
|
||||||
-e TIMEZONE="America/New_York" \
|
-e TIMEZONE="America/New_York" \
|
||||||
|
-e DATE_TIME_FORMAT="m/d/y h:iA" \
|
||||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
-e TOTAL_UPLOAD_SIZE="5G" \
|
||||||
-e SECURE="false" \
|
-e SECURE="false" \
|
||||||
|
-e PERSISTENT_TOKENS_KEY="please_change_this_@@" \
|
||||||
|
-e PUID="1000" \
|
||||||
|
-e PGID="1000" \
|
||||||
|
-e CHOWN_ON_START="true" \
|
||||||
|
-e SCAN_ON_START="true" \
|
||||||
|
-e SHARE_URL="" \
|
||||||
-v ~/filerise/uploads:/var/www/uploads \
|
-v ~/filerise/uploads:/var/www/uploads \
|
||||||
-v ~/filerise/users:/var/www/users \
|
-v ~/filerise/users:/var/www/users \
|
||||||
-v ~/filerise/metadata:/var/www/metadata \
|
-v ~/filerise/metadata:/var/www/metadata \
|
||||||
--name filerise \
|
|
||||||
error311/filerise-docker:latest
|
error311/filerise-docker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start FileRise on port 8080. Visit `http://your-server-ip:8080` to access it. Environment variables shown above are optional – for instance, set `SECURE="true"` to enforce HTTPS (assuming you have SSL at proxy level) and adjust `TIMEZONE` as needed. The volume mounts ensure your files and user data persist outside the container.
|
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
||||||
|
|
||||||
- **Using Docker Compose:**
|
**Notes**
|
||||||
Alternatively, use **docker-compose**. Save the snippet below as docker-compose.yml and run `docker-compose up -d`:
|
|
||||||
|
|
||||||
``` yaml
|
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
|
||||||
version: '3'
|
- `CHOWN_ON_START=true` is recommended on **first run**. Set to **false** later for faster restarts.
|
||||||
|
- `SCAN_ON_START=true` indexes files added outside the UI so their metadata appears.
|
||||||
|
- `SHARE_URL` optional; leave blank to auto-detect host/scheme. Set to site root (e.g., `https://files.example.com`) if needed.
|
||||||
|
- Set `SECURE="true"` if you serve via HTTPS at your proxy layer.
|
||||||
|
|
||||||
|
**Verify ownership mapping (optional)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it filerise id www-data
|
||||||
|
# expect: uid=1000 gid=1000 (or 99/100 on Unraid)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using Docker Compose
|
||||||
|
|
||||||
|
Save as `docker-compose.yml`, then `docker-compose up -d`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3"
|
||||||
services:
|
services:
|
||||||
filerise:
|
filerise:
|
||||||
image: error311/filerise-docker:latest
|
image: error311/filerise-docker:latest
|
||||||
@@ -91,75 +126,113 @@ services:
|
|||||||
- "8080:80"
|
- "8080:80"
|
||||||
environment:
|
environment:
|
||||||
TIMEZONE: "UTC"
|
TIMEZONE: "UTC"
|
||||||
|
DATE_TIME_FORMAT: "m/d/y h:iA"
|
||||||
TOTAL_UPLOAD_SIZE: "10G"
|
TOTAL_UPLOAD_SIZE: "10G"
|
||||||
SECURE: "false"
|
SECURE: "false"
|
||||||
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
|
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
|
||||||
|
# Ownership & indexing
|
||||||
|
PUID: "1000" # Unraid users often use 99
|
||||||
|
PGID: "1000" # Unraid users often use 100
|
||||||
|
CHOWN_ON_START: "true" # first run; set to "false" afterwards
|
||||||
|
SCAN_ON_START: "true" # index files added outside the UI at boot
|
||||||
|
# Sharing URL (optional): leave blank to auto-detect from host/scheme
|
||||||
|
SHARE_URL: ""
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/var/www/uploads
|
- ./uploads:/var/www/uploads
|
||||||
- ./users:/var/www/users
|
- ./users:/var/www/users
|
||||||
- ./metadata:/var/www/metadata
|
- ./metadata:/var/www/metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
FileRise will be accessible at `http://localhost:8080` (or your server’s IP). The above example also sets a custom `PERSISTENT_TOKENS_KEY` (used to encrypt “remember me” tokens) – be sure to change it to a random string for security.
|
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.
|
||||||
|
|
||||||
**First-time Setup:** On first launch, FileRise will detect no users and prompt you to create an **Admin account**. Choose your admin username & password, and you’re in! You can then head to the **User Management** section to add additional users if needed.
|
**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.
|
||||||
### 2. Manual Installation (PHP/Apache)
|
|
||||||
|
|
||||||
If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
|
|
||||||
|
|
||||||
- **Requirements:** PHP 8.3 or higher, Apache (with mod_php) or another web server configured for PHP. Ensure PHP extensions json, curl, and zip are enabled. No database needed.
|
|
||||||
- **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases).
|
|
||||||
|
|
||||||
``` bash
|
|
||||||
git clone https://github.com/error311/FileRise.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Place the files into your web server’s directory (e.g., `/var/www/`). It can be in a subfolder (just adjust the `BASE_URL` in config as below).
|
|
||||||
|
|
||||||
- **Composer Dependencies:** Install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.)
|
|
||||||
|
|
||||||
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they don’t exist):
|
|
||||||
|
|
||||||
``` bash
|
|
||||||
mkdir -p uploads users metadata
|
|
||||||
chown -R www-data:www-data uploads users metadata # www-data is Apache user; use appropriate user
|
|
||||||
chmod -R 775 uploads users metadata
|
|
||||||
```
|
|
||||||
|
|
||||||
The uploads/ folder is where files go, users/ stores the user credentials file, and metadata/ holds metadata like tags and share links.
|
|
||||||
|
|
||||||
- **Configuration:** Open the `config.php` file in a text editor. You may want to adjust:
|
|
||||||
|
|
||||||
- `BASE_URL` – the URL where you will access FileRise (e.g., `“https://files.mydomain.com/”`). This is used for generating share links.
|
|
||||||
|
|
||||||
- `TIMEZONE` and `DATE_TIME_FORMAT` – match your locale (for correct timestamps).
|
|
||||||
|
|
||||||
- `TOTAL_UPLOAD_SIZE` – max aggregate upload size (default 5G). Also adjust PHP’s `upload_max_filesize` and `post_max_size` to at least this value (the Docker start script auto-adjusts PHP limits).
|
|
||||||
|
|
||||||
- `PERSISTENT_TOKENS_KEY` – set a unique secret if you use “Remember Me” logins, to encrypt the tokens.
|
|
||||||
|
|
||||||
- Other settings like `UPLOAD_DIR`, `USERS_FILE` etc. generally don’t need changes unless you move those folders. Defaults are set for the directories mentioned above.
|
|
||||||
|
|
||||||
- **Web Server Config:** If using Apache, ensure `.htaccess` files are allowed or manually add the rules from `.htaccess` to your Apache config – these disable directory listings and prevent access to certain files. For Nginx or others, you’ll need to replicate those protections (see Wiki: [Nginx Setup for examples](https://github.com/error311/FileRise/wiki/Nginx-Setup)). Also enable mod_rewrite if not already, as FileRise may use pretty URLs for share links.
|
|
||||||
|
|
||||||
Now navigate to the FileRise URL in your browser. On first load, you’ll be prompted to create the Admin user (same as Docker setup). After that, the application is ready to use!
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick‑start: Mount via WebDAV
|
### 2) Manual Installation (PHP/Apache)
|
||||||
|
|
||||||
Once FileRise is running, you must enable WebDAV in admin panel to access it.
|
If you prefer a traditional web server (LAMP stack or similar):
|
||||||
|
|
||||||
|
**Requirements**
|
||||||
|
|
||||||
|
- PHP **8.3+**
|
||||||
|
- Apache (mod_php) or another web server configured for PHP
|
||||||
|
- PHP extensions: `json`, `curl`, `zip` (and typical defaults). No database required.
|
||||||
|
|
||||||
|
**Download Files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/error311/FileRise.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine.
|
||||||
|
|
||||||
|
**Composer (if applicable)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
```
|
||||||
|
|
||||||
|
**Folders & Permissions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p uploads users metadata
|
||||||
|
chown -R www-data:www-data uploads users metadata # use your web user
|
||||||
|
chmod -R 775 uploads users metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
- `uploads/`: actual files
|
||||||
|
- `users/`: credentials & token storage
|
||||||
|
- `metadata/`: file metadata (tags, share links, etc.)
|
||||||
|
|
||||||
|
**Configuration**
|
||||||
|
|
||||||
|
Edit `config.php`:
|
||||||
|
|
||||||
|
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
|
||||||
|
- `TOTAL_UPLOAD_SIZE` (ensure PHP `upload_max_filesize` and `post_max_size` meet/exceed this).
|
||||||
|
- `PERSISTENT_TOKENS_KEY` for “Remember Me” tokens.
|
||||||
|
|
||||||
|
**Share link base URL**
|
||||||
|
|
||||||
|
- Set **`SHARE_URL`** via web-server env vars (preferred),
|
||||||
|
**or** keep using `BASE_URL` in `config.php` as a fallback.
|
||||||
|
- If neither is set, FileRise auto-detects from the current host/scheme.
|
||||||
|
|
||||||
|
**Web server config**
|
||||||
|
|
||||||
|
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
|
||||||
|
- Nginx/other: replicate basic protections (no directory listing, deny sensitive files). See Wiki for examples.
|
||||||
|
|
||||||
|
Browse to your FileRise URL; you’ll be prompted to create the Admin user on first load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unraid
|
||||||
|
|
||||||
|
- Install from **Community Apps** → search **FileRise**.
|
||||||
|
- Default **bridge**: access at `http://SERVER_IP:8080/`.
|
||||||
|
- **Custom br0** (own IP): map host ports to **80/443** if you want bare `http://CONTAINER_IP/` without a port.
|
||||||
|
- See the [support thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific help.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick-start: Mount via WebDAV
|
||||||
|
|
||||||
|
Once FileRise is running, enable WebDAV in the admin panel.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux (GVFS/GIO)
|
# Linux (GVFS/GIO)
|
||||||
gio mount dav://demo@your-host/webdav.php/
|
gio mount dav://demo@your-host/webdav.php/
|
||||||
|
|
||||||
# macOS (Finder → Go → Connect to Server…)
|
# macOS (Finder → Go → Connect to Server…)
|
||||||
dav://demo@your-host/webdav.php/
|
https://your-host/webdav.php/
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Finder typically uses `https://` (or `http://`) URLs for WebDAV, while GNOME/KDE use `dav://` / `davs://`.
|
||||||
|
|
||||||
### Windows (File Explorer)
|
### Windows (File Explorer)
|
||||||
|
|
||||||
- Open **File Explorer** → Right-click **This PC** → **Map network drive…**
|
- Open **File Explorer** → Right-click **This PC** → **Map network drive…**
|
||||||
@@ -170,8 +243,8 @@ dav://demo@your-host/webdav.php/
|
|||||||
https://your-host/webdav.php/
|
https://your-host/webdav.php/
|
||||||
```
|
```
|
||||||
|
|
||||||
- Check **Connect using different credentials**, and enter your FileRise username and password.
|
- Check **Connect using different credentials**, then enter your FileRise username/password.
|
||||||
- Click **Finish**. The drive will now appear under **This PC**.
|
- Click **Finish**.
|
||||||
|
|
||||||
> **Important:**
|
> **Important:**
|
||||||
> Windows requires HTTPS (SSL) for WebDAV connections by default.
|
> Windows requires HTTPS (SSL) for WebDAV connections by default.
|
||||||
@@ -186,41 +259,43 @@ dav://demo@your-host/webdav.php/
|
|||||||
>
|
>
|
||||||
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
|
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
|
||||||
> 4. Set its value to `2`.
|
> 4. Set its value to `2`.
|
||||||
> 5. Restart the **WebClient** service or reboot your computer.
|
> 5. Restart the **WebClient** service or reboot.
|
||||||
|
|
||||||
📖 For a full guide (including SSL setup, HTTP workaround, and troubleshooting), see the [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV).
|
📖 See the full [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV) for SSL setup, HTTP workaround, and troubleshooting.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FAQ / Troubleshooting
|
## FAQ / Troubleshooting
|
||||||
|
|
||||||
- **“Upload failed” or large files not uploading:** Make sure `TOTAL_UPLOAD_SIZE` in config and PHP’s `post_max_size` / `upload_max_filesize` are all set high enough. For extremely large files, you might also need to increase max_execution_time in PHP or rely on the resumable upload feature in smaller chunks.
|
- **“Upload failed” or large files not uploading:** Ensure `TOTAL_UPLOAD_SIZE` in config and PHP’s `post_max_size` / `upload_max_filesize` are set high enough. For extremely large files, you might need to increase `max_execution_time` or rely on resumable uploads in smaller chunks.
|
||||||
|
|
||||||
- **How to enable HTTPS?** FileRise itself doesn’t handle TLS. Run it behind a reverse proxy like Nginx, Caddy, or Apache with SSL, or use Docker with a companion like nginx-proxy or Caddy. Set `SECURE="true"` env var in Docker so FileRise knows to generate https links.
|
- **How to enable HTTPS?** FileRise doesn’t terminate TLS itself. Run it behind a reverse proxy (Nginx, Caddy, Apache with SSL) or use a companion like nginx-proxy or Caddy in Docker. Set `SECURE="true"` in Docker so FileRise generates HTTPS links.
|
||||||
|
|
||||||
- **Changing Admin or resetting password:** Admin can change any user’s password via the UI (User Management section). If you lose admin access, you can edit the `users/users.txt` file on the server – passwords are hashed (bcrypt), but you can delete the admin line and then restart the app to trigger the setup flow again.
|
- **Changing Admin or resetting password:** Admin can change any user’s password via **User Management**. If you lose admin access, edit the `users/users.txt` file on the server – passwords are hashed (bcrypt), but you can delete the admin line and restart the app to trigger the setup flow again.
|
||||||
|
|
||||||
- **Where are my files stored?** In the `uploads/` directory (or the path you set for `UPLOAD_DIR`). Within it, files are organized in the folder structure you see in the app. Deleted files move to `uploads/trash/`. Tag information is in `metadata/file_metadata`.json and trash metadata in `metadata/trash.json`, etc. Regular backups of these folders is recommended if the data is important.
|
- **Where are my files stored?** In the `uploads/` directory (or the path you set). Deleted files move to `uploads/trash/`. Tag information is in `metadata/file_metadata.json` and trash metadata in `metadata/trash.json`, etc. Backups are recommended.
|
||||||
|
|
||||||
- **Updating FileRise:** If using Docker, pull the new image and recreate the container. For manual installs, download the latest release and replace the files (preserve your `config.php` and the uploads/users/metadata folders). Clear your browser cache if you have issues after an update (in case CSS/JS changed).
|
- **Updating FileRise:** For Docker, pull the new image and recreate the container. For manual installs, download the latest release and replace files (keep your `config.php` and `uploads/users/metadata`). Clear your browser cache if UI assets changed.
|
||||||
|
|
||||||
For more Q&A or to ask for help, please check the Discussions or open an issue.
|
For more Q&A or to ask for help, open a Discussion or Issue.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! If you have ideas for new features or have found a bug, feel free to open an issue. Check out the [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. You can also join the conversation in GitHub Discussions or on Reddit (see links below) to share feedback and suggestions.
|
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
Areas to help: translations, bug fixes, UI polish, integrations.
|
||||||
Areas where you can help: translations, bug fixes, UI improvements, or building integration with other services. If you like FileRise, giving the project a ⭐ star ⭐ on GitHub is also a much-appreciated contribution!
|
If you like FileRise, a ⭐ star on GitHub is much appreciated!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Community and Support
|
## Community and Support
|
||||||
|
|
||||||
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1jl01pi/introducing_filerise_a_modern_selfhosted_file/) – (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).
|
||||||
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) – for Unraid-specific support or issues.
|
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) – for Unraid-specific support or issues.
|
||||||
- **GitHub Discussions:** Use the Q&A category for any setup questions, and the Ideas category to suggest enhancements.
|
- **GitHub Discussions:** Use Q&A for setup questions, Ideas for enhancements.
|
||||||
|
|
||||||
|
[](https://star-history.com/#error311/FileRise&Date)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -253,4 +328,4 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is open-source under the MIT License. That means you’re free to use, modify, and distribute **FileRise**, with attribution. We hope you find it useful and contribute back!
|
MIT License – see [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
FileRise is actively maintained. Only supported versions will receive security updates. For details on which versions are currently supported, please see the [Release Notes](https://github.com/error311/FileRise/releases).
|
We provide security fixes for the latest minor release line.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|------------|-----------|
|
||||||
|
| v1.5.x | ✅ |
|
||||||
|
| < v1.5.0 | ❌ |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
|||||||
@@ -28,13 +28,18 @@ define('TRASH_DIR', UPLOAD_DIR . 'trash/');
|
|||||||
define('TIMEZONE', 'America/New_York');
|
define('TIMEZONE', 'America/New_York');
|
||||||
define('DATE_TIME_FORMAT','m/d/y h:iA');
|
define('DATE_TIME_FORMAT','m/d/y h:iA');
|
||||||
define('TOTAL_UPLOAD_SIZE','5G');
|
define('TOTAL_UPLOAD_SIZE','5G');
|
||||||
define('REGEX_FOLDER_NAME', '/^[\p{L}\p{N}_\-\s\/\\\\]+$/u');
|
define('REGEX_FOLDER_NAME','/^(?!^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)(?!.*[. ]$)(?:[^<>:"\/\\\\|?*\x00-\x1F]{1,255})(?:[\/\\\\][^<>:"\/\\\\|?*\x00-\x1F]{1,255})*$/xu');
|
||||||
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
||||||
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
|
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
|
||||||
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
||||||
|
|
||||||
date_default_timezone_set(TIMEZONE);
|
date_default_timezone_set(TIMEZONE);
|
||||||
|
|
||||||
|
if (!defined('DEFAULT_BYPASS_OWNERSHIP')) define('DEFAULT_BYPASS_OWNERSHIP', false);
|
||||||
|
if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true);
|
||||||
|
if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
|
||||||
|
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
|
||||||
|
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
|
||||||
|
|
||||||
// Encryption helpers
|
// Encryption helpers
|
||||||
function encryptData($data, $encryptionKey)
|
function encryptData($data, $encryptionKey)
|
||||||
@@ -70,16 +75,27 @@ function loadUserPermissions($username)
|
|||||||
{
|
{
|
||||||
global $encryptionKey;
|
global $encryptionKey;
|
||||||
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
||||||
if (file_exists($permissionsFile)) {
|
if (!file_exists($permissionsFile)) {
|
||||||
$content = file_get_contents($permissionsFile);
|
return false;
|
||||||
$decrypted = decryptData($content, $encryptionKey);
|
|
||||||
$json = ($decrypted !== false) ? $decrypted : $content;
|
|
||||||
$perms = json_decode($json, true);
|
|
||||||
if (is_array($perms) && isset($perms[$username])) {
|
|
||||||
return !empty($perms[$username]) ? $perms[$username] : false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
$content = file_get_contents($permissionsFile);
|
||||||
|
$decrypted = decryptData($content, $encryptionKey);
|
||||||
|
$json = ($decrypted !== false) ? $decrypted : $content;
|
||||||
|
$permsAll = json_decode($json, true);
|
||||||
|
|
||||||
|
if (!is_array($permsAll)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try exact match first, then lowercase (since we store keys lowercase elsewhere)
|
||||||
|
$uExact = (string)$username;
|
||||||
|
$uLower = strtolower($uExact);
|
||||||
|
|
||||||
|
$row = $permsAll[$uExact] ?? $permsAll[$uLower] ?? null;
|
||||||
|
|
||||||
|
// Normalize: always return an array when found, else false (to preserve current callers’ behavior)
|
||||||
|
return is_array($row) ? $row : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine HTTPS usage
|
// Determine HTTPS usage
|
||||||
@@ -89,25 +105,39 @@ $secure = ($envSecure !== false)
|
|||||||
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||||
|
|
||||||
// Choose session lifetime based on "remember me" cookie
|
// Choose session lifetime based on "remember me" cookie
|
||||||
$defaultSession = 7200; // 2 hours
|
$defaultSession = 7200; // 2 hours
|
||||||
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
||||||
$sessionLifetime = isset($_COOKIE['remember_me_token'])
|
$sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession;
|
||||||
? $persistentDays
|
|
||||||
: $defaultSession;
|
|
||||||
|
|
||||||
// Configure PHP session cookie and GC
|
|
||||||
session_set_cookie_params([
|
|
||||||
'lifetime' => $sessionLifetime,
|
|
||||||
'path' => '/',
|
|
||||||
'domain' => '', // adjust if you need a specific domain
|
|
||||||
'secure' => $secure,
|
|
||||||
'httponly' => true,
|
|
||||||
'samesite' => 'Lax'
|
|
||||||
]);
|
|
||||||
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start session idempotently:
|
||||||
|
* - If no session: set cookie params + gc_maxlifetime, then session_start().
|
||||||
|
* - If session already active: DO NOT change ini/cookie params; optionally refresh cookie expiry.
|
||||||
|
*/
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => $sessionLifetime,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => '', // adjust if you need a specific domain
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax'
|
||||||
|
]);
|
||||||
|
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
||||||
session_start();
|
session_start();
|
||||||
|
} else {
|
||||||
|
// Optionally refresh the session cookie expiry to keep the user alive
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
if ($sessionLifetime > 0) {
|
||||||
|
setcookie(session_name(), session_id(), [
|
||||||
|
'expires' => time() + $sessionLifetime,
|
||||||
|
'path' => $params['path'] ?: '/',
|
||||||
|
'domain' => $params['domain'] ?? '',
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => $params['samesite'] ?? 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF token
|
// CSRF token
|
||||||
@@ -115,8 +145,7 @@ if (empty($_SESSION['csrf_token'])) {
|
|||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-login via persistent token
|
||||||
// Auto‑login via persistent token
|
|
||||||
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
|
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
|
||||||
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
||||||
$tokens = [];
|
$tokens = [];
|
||||||
@@ -196,13 +225,21 @@ if (AUTH_BYPASS) {
|
|||||||
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Share URL fallback
|
|
||||||
|
// Share URL fallback (keep BASE_URL behavior)
|
||||||
define('BASE_URL', 'http://yourwebsite/uploads/');
|
define('BASE_URL', 'http://yourwebsite/uploads/');
|
||||||
|
|
||||||
|
// Detect scheme correctly (works behind proxies too)
|
||||||
|
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? (
|
||||||
|
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'
|
||||||
|
);
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
|
||||||
if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
||||||
$defaultShare = isset($_SERVER['HTTP_HOST'])
|
$defaultShare = "{$proto}://{$host}/api/file/share.php";
|
||||||
? "http://{$_SERVER['HTTP_HOST']}/api/file/share.php"
|
|
||||||
: "http://localhost/api/file/share.php";
|
|
||||||
} else {
|
} else {
|
||||||
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
|
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final: env var wins, else fallback
|
||||||
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
||||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
filerise:
|
||||||
|
# Use the published image (does NOT build in CI by default)
|
||||||
|
image: error311/filerise-docker:latest
|
||||||
|
container_name: filerise
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# If someone wants to build locally instead, they can uncomment:
|
||||||
|
# build:
|
||||||
|
# context: .
|
||||||
|
# dockerfile: Dockerfile
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "${HOST_HTTP_PORT:-8080}:80"
|
||||||
|
# Uncomment if you really terminate TLS inside the container:
|
||||||
|
# - "${HOST_HTTPS_PORT:-8443}:443"
|
||||||
|
|
||||||
|
environment:
|
||||||
|
TIMEZONE: "${TIMEZONE:-UTC}"
|
||||||
|
DATE_TIME_FORMAT: "${DATE_TIME_FORMAT:-m/d/y h:iA}"
|
||||||
|
TOTAL_UPLOAD_SIZE: "${TOTAL_UPLOAD_SIZE:-5G}"
|
||||||
|
SECURE: "${SECURE:-false}"
|
||||||
|
PERSISTENT_TOKENS_KEY: "${PERSISTENT_TOKENS_KEY:-please_change_this_@@}"
|
||||||
|
PUID: "${PUID:-1000}"
|
||||||
|
PGID: "${PGID:-1000}"
|
||||||
|
CHOWN_ON_START: "${CHOWN_ON_START:-true}"
|
||||||
|
SCAN_ON_START: "${SCAN_ON_START:-true}"
|
||||||
|
SHARE_URL: "${SHARE_URL:-}"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./data/uploads:/var/www/uploads
|
||||||
|
- ./data/users:/var/www/users
|
||||||
|
- ./data/metadata:/var/www/metadata
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://localhost/ || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
79
public/api/admin/acl/getGrants.php
Normal file
79
public/api/admin/acl/getGrants.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/admin/acl/getGrants.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Admin only
|
||||||
|
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
|
||||||
|
http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = trim((string)($_GET['user'] ?? ''));
|
||||||
|
if ($user === '' || !preg_match(REGEX_USER, $user)) {
|
||||||
|
http_response_code(400); echo json_encode(['error'=>'Invalid user']); exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the folder list (admin sees all)
|
||||||
|
$folders = [];
|
||||||
|
try {
|
||||||
|
$rows = FolderModel::getFolderList();
|
||||||
|
if (is_array($rows)) {
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$f = is_array($r) ? ($r['folder'] ?? '') : (string)$r;
|
||||||
|
if ($f !== '') $folders[$f] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) { /* ignore */ }
|
||||||
|
|
||||||
|
if (empty($folders)) {
|
||||||
|
$aclPath = META_DIR . 'folder_acl.json';
|
||||||
|
if (is_file($aclPath)) {
|
||||||
|
$data = json_decode((string)@file_get_contents($aclPath), true);
|
||||||
|
if (is_array($data['folders'] ?? null)) {
|
||||||
|
foreach ($data['folders'] as $name => $_) $folders[$name] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$folderList = array_keys($folders);
|
||||||
|
if (!in_array('root', $folderList, true)) array_unshift($folderList, 'root');
|
||||||
|
|
||||||
|
$has = function(array $arr, string $u): bool {
|
||||||
|
foreach ($arr as $x) if (strcasecmp((string)$x, $u) === 0) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($folderList as $f) {
|
||||||
|
$rec = ACL::explicit($f); // owners, read, write, share, read_own
|
||||||
|
|
||||||
|
$isOwner = $has($rec['owners'], $user);
|
||||||
|
$canUpload = $isOwner || $has($rec['write'], $user);
|
||||||
|
|
||||||
|
// IMPORTANT: full view only if owner or explicit read
|
||||||
|
$canViewAll = $isOwner || $has($rec['read'], $user);
|
||||||
|
|
||||||
|
// own-only view reflects explicit read_own (we keep it separate even if they have full view)
|
||||||
|
$canViewOwn = $has($rec['read_own'], $user);
|
||||||
|
|
||||||
|
// Share only if owner or explicit share
|
||||||
|
$canShare = $isOwner || $has($rec['share'], $user);
|
||||||
|
|
||||||
|
if ($canViewAll || $canViewOwn || $canUpload || $isOwner || $canShare) {
|
||||||
|
$out[$f] = [
|
||||||
|
'view' => $canViewAll,
|
||||||
|
'viewOwn' => $canViewOwn,
|
||||||
|
'upload' => $canUpload,
|
||||||
|
'manage' => $isOwner,
|
||||||
|
'share' => $canShare,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);
|
||||||
105
public/api/admin/acl/saveGrants.php
Normal file
105
public/api/admin/acl/saveGrants.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/admin/acl/saveGrants.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// ---- Auth + CSRF -----------------------------------------------------------
|
||||||
|
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = function_exists('getallheaders') ? array_change_key_case(getallheaders(), CASE_LOWER) : [];
|
||||||
|
$csrf = trim($headers['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
|
||||||
|
|
||||||
|
if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ---------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Sanitize a grants map to allowed flags only:
|
||||||
|
* view | viewOwn | upload | manage | share
|
||||||
|
*/
|
||||||
|
function sanitize_grants_map(array $grants): array {
|
||||||
|
$allowed = ['view','viewOwn','upload','manage','share'];
|
||||||
|
$out = [];
|
||||||
|
foreach ($grants as $folder => $caps) {
|
||||||
|
if (!is_string($folder)) $folder = (string)$folder;
|
||||||
|
if (!is_array($caps)) $caps = [];
|
||||||
|
$row = [];
|
||||||
|
foreach ($allowed as $k) {
|
||||||
|
$row[$k] = !empty($caps[$k]);
|
||||||
|
}
|
||||||
|
// include folder even if all false (signals "remove all for this user on this folder")
|
||||||
|
$out[$folder] = $row;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valid_user(string $u): bool {
|
||||||
|
return ($u !== '' && preg_match(REGEX_USER, $u));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Read JSON body --------------------------------------------------------
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$in = json_decode((string)$raw, true);
|
||||||
|
if (!is_array($in)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Invalid JSON']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Single user mode: { user, grants } ------------------------------------
|
||||||
|
if (isset($in['user']) && isset($in['grants']) && is_array($in['grants'])) {
|
||||||
|
$user = trim((string)$in['user']);
|
||||||
|
if (!valid_user($user)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Invalid user']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$grants = sanitize_grants_map($in['grants']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$res = ACL::applyUserGrantsAtomic($user, $grants);
|
||||||
|
echo json_encode($res, JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Batch mode: { changes: [ { user, grants }, ... ] } --------------------
|
||||||
|
if (isset($in['changes']) && is_array($in['changes'])) {
|
||||||
|
$updated = [];
|
||||||
|
foreach ($in['changes'] as $chg) {
|
||||||
|
if (!is_array($chg)) continue;
|
||||||
|
$user = trim((string)($chg['user'] ?? ''));
|
||||||
|
$gr = $chg['grants'] ?? null;
|
||||||
|
if (!valid_user($user) || !is_array($gr)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$res = ACL::applyUserGrantsAtomic($user, sanitize_grants_map($gr));
|
||||||
|
$updated[$user] = $res['updated'] ?? [];
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$updated[$user] = ['error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo json_encode(['ok' => true, 'updated' => $updated], JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Fallback --------------------------------------------------------------
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);
|
||||||
15
public/api/file/createFile.php
Normal file
15
public/api/file/createFile.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/file/createFile.php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if (empty($_SESSION['authenticated'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success'=>false,'error'=>'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fc = new FileController();
|
||||||
|
$fc->createFile();
|
||||||
120
public/api/folder/capabilities.php
Normal file
120
public/api/folder/capabilities.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/folder/capabilities.php
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// --- auth ---
|
||||||
|
$username = $_SESSION['username'] ?? '';
|
||||||
|
if ($username === '') {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
function loadPermsFor(string $u): array {
|
||||||
|
try {
|
||||||
|
if (function_exists('loadUserPermissions')) {
|
||||||
|
$p = loadUserPermissions($u);
|
||||||
|
return is_array($p) ? $p : [];
|
||||||
|
}
|
||||||
|
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
|
||||||
|
$all = userModel::getUserPermissions();
|
||||||
|
if (is_array($all)) {
|
||||||
|
if (isset($all[$u])) return (array)$all[$u];
|
||||||
|
$lk = strtolower($u);
|
||||||
|
if (isset($all[$lk])) return (array)$all[$lk];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdminUser(string $u, array $perms): bool {
|
||||||
|
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
|
||||||
|
if (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) return true;
|
||||||
|
$role = $_SESSION['role'] ?? null;
|
||||||
|
if ($role === 'admin' || $role === '1' || $role === 1) return true;
|
||||||
|
if ($u) {
|
||||||
|
$r = userModel::getUserRole($u);
|
||||||
|
if ($r === '1') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
|
||||||
|
if ($isAdmin) return true;
|
||||||
|
$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
|
||||||
|
if (!$folderOnly) return true;
|
||||||
|
$f = trim($folder);
|
||||||
|
if ($f === '' || strcasecmp($f, 'root') === 0) return false; // non-admin folderOnly: not root
|
||||||
|
return ($f === $u) || (strpos($f, $u . '/') === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- inputs ---
|
||||||
|
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||||
|
// validate folder path: allow "root" or nested segments matching REGEX_FOLDER_NAME
|
||||||
|
if ($folder !== 'root') {
|
||||||
|
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
|
||||||
|
if (empty($parts)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Invalid folder name.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
foreach ($parts as $seg) {
|
||||||
|
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Invalid folder name.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$folder = implode('/', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
$perms = loadPermsFor($username);
|
||||||
|
$isAdmin = isAdminUser($username, $perms);
|
||||||
|
|
||||||
|
// base permissions via ACL
|
||||||
|
$canRead = $isAdmin || ACL::canRead($username, $perms, $folder);
|
||||||
|
$canWrite = $isAdmin || ACL::canWrite($username, $perms, $folder);
|
||||||
|
$canShare = $isAdmin || ACL::canShare($username, $perms, $folder);
|
||||||
|
|
||||||
|
// scope + flags
|
||||||
|
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
||||||
|
$readOnly = !empty($perms['readOnly']);
|
||||||
|
$disableUpload = !empty($perms['disableUpload']);
|
||||||
|
|
||||||
|
$canUpload = $canWrite && !$readOnly && !$disableUpload && $inScope;
|
||||||
|
$canCreateFolder = $canWrite && !$readOnly && $inScope;
|
||||||
|
$canRename = $canWrite && !$readOnly && $inScope;
|
||||||
|
$canDelete = $canWrite && !$readOnly && $inScope;
|
||||||
|
$canMoveIn = $canWrite && !$readOnly && $inScope;
|
||||||
|
|
||||||
|
// (optional) owner info if you need it client-side
|
||||||
|
$owner = FolderModel::getOwnerFor($folder);
|
||||||
|
|
||||||
|
// output
|
||||||
|
echo json_encode([
|
||||||
|
'user' => $username,
|
||||||
|
'folder' => $folder,
|
||||||
|
'isAdmin' => $isAdmin,
|
||||||
|
'flags' => [
|
||||||
|
'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
||||||
|
'readOnly' => $readOnly,
|
||||||
|
'disableUpload' => $disableUpload,
|
||||||
|
],
|
||||||
|
'owner' => $owner,
|
||||||
|
'canView' => $canRead,
|
||||||
|
'canUpload' => $canUpload,
|
||||||
|
'canCreate' => $canCreateFolder,
|
||||||
|
'canRename' => $canRename,
|
||||||
|
'canDelete' => $canDelete,
|
||||||
|
'canMoveIn' => $canMoveIn,
|
||||||
|
'canShare' => $canShare,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
15
public/api/profile/getCurrentUser.php
Normal file
15
public/api/profile/getCurrentUser.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if (empty($_SESSION['authenticated'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error'=>'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $_SESSION['username'];
|
||||||
|
$data = UserModel::getUser($user);
|
||||||
|
echo json_encode($data);
|
||||||
17
public/api/profile/uploadPicture.php
Normal file
17
public/api/profile/uploadPicture.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
// Always JSON, even on PHP notices
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$userController = new UserController();
|
||||||
|
$userController->uploadPicture();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Exception: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
BIN
public/assets/default-avatar.png
Normal file
BIN
public/assets/default-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -134,17 +134,27 @@ body.dark-mode header {
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 9px;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-buttons button:not(#userDropdownToggle) {
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#userDropdownToggle {
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 6px 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.header-buttons button:hover {
|
.header-buttons button:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
header {
|
header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -838,6 +848,27 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
|
|||||||
background-color: #00796B;
|
background-color: #00796B;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#createBtn {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .dropdown-menu {
|
||||||
|
background-color: #2c2c2c !important;
|
||||||
|
border-color: #444 !important;
|
||||||
|
color: #e0e0e0!important;
|
||||||
|
}
|
||||||
|
body.dark-mode .dropdown-menu .dropdown-item {
|
||||||
|
color: #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
body.dark-mode .dropdown-item:hover {
|
||||||
|
background-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
#fileList button.edit-btn {
|
#fileList button.edit-btn {
|
||||||
background-color: #007bff;
|
background-color: #007bff;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -955,6 +986,29 @@ body.dark-mode #fileList table tr {
|
|||||||
padding: 8px 10px !important;
|
padding: 8px 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--file-row-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.table tbody tr {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: var(--file-row-height) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.table tbody td:not(.file-name-cell) {
|
||||||
|
height: var(--file-row-height) !important;
|
||||||
|
line-height: var(--file-row-height) !important;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileList table.table tbody td.file-name-cell {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.2em !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===========================================================
|
/* ===========================================================
|
||||||
HEADINGS & FORM LABELS
|
HEADINGS & FORM LABELS
|
||||||
@@ -1328,26 +1382,6 @@ body.dark-mode .image-preview-modal-content {
|
|||||||
border-color: #444;
|
border-color: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-btn,
|
|
||||||
.download-btn,
|
|
||||||
.rename-btn,
|
|
||||||
.share-btn,
|
|
||||||
.edit-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 12px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn {
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-left: 0px;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-modal-img {
|
.image-modal-img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
@@ -2102,13 +2136,23 @@ body.dark-mode .header-drop-zone.drag-active {
|
|||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
#fileSummary {
|
#fileSummary,
|
||||||
float: none !important;
|
#rowHeightSliderContainer,
|
||||||
margin: 0 auto !important;
|
#viewSliderContainer {
|
||||||
text-align: center !important;
|
float: none !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
text-align: center !important;
|
||||||
|
display: block !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#viewSliderContainer label,
|
||||||
|
#viewSliderContainer span {
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode #fileSummary {
|
body.dark-mode #fileSummary {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -2165,4 +2209,100 @@ body.dark-mode #searchIcon .material-icons {
|
|||||||
body.dark-mode .btn-icon:hover,
|
body.dark-mode .btn-icon:hover,
|
||||||
body.dark-mode .btn-icon:focus {
|
body.dark-mode .btn-icon:focus {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .user-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
background: var(--bs-body-bg, #fff);
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 150px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .user-menu.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .user-menu .item {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.user-dropdown .user-menu .item:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .dropdown-caret {
|
||||||
|
border-top: 5px solid currentColor;
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-right: 5px solid transparent;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .user-dropdown .user-menu {
|
||||||
|
background: #2c2c2c;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .user-dropdown .user-menu .item {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .user-dropdown .user-menu .item:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown .dropdown-username {
|
||||||
|
margin: 0 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 80px;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-item i.material-icons {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.folder-strip-container .folder-name {
|
||||||
|
text-align: center;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 80px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container .folder-item i.material-icons {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container .folder-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
@@ -11,13 +11,18 @@
|
|||||||
<meta name="share-url" content="">
|
<meta name="share-url" content="">
|
||||||
<style>
|
<style>
|
||||||
/* hide the app shell until JS says otherwise */
|
/* hide the app shell until JS says otherwise */
|
||||||
.main-wrapper { display: none; }
|
.main-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* full-screen white overlay while we check auth */
|
/* full-screen white overlay while we check auth */
|
||||||
#loadingOverlay {
|
#loadingOverlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0;
|
||||||
background: var(--bg-color,#fff);
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-color, #fff);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -135,9 +140,6 @@
|
|||||||
<!-- Your header drop zone -->
|
<!-- Your header drop zone -->
|
||||||
<div id="headerDropArea" class="header-drop-zone"></div>
|
<div id="headerDropArea" class="header-drop-zone"></div>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<button id="logoutBtn" data-i18n-title="logout">
|
|
||||||
<i class="material-icons">exit_to_app</i>
|
|
||||||
</button>
|
|
||||||
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
||||||
<i class="material-icons">vpn_key</i>
|
<i class="material-icons">vpn_key</i>
|
||||||
</button>
|
</button>
|
||||||
@@ -387,8 +389,55 @@
|
|||||||
</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 btn-sm btn-info" data-i18n-title="extract_zip"
|
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" data-i18n-title="extract_zip"
|
||||||
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;">
|
||||||
|
<button id="createBtn" class="btn action-btn" data-i18n-key="create">
|
||||||
|
${t('create')} <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
||||||
|
</button>
|
||||||
|
<ul
|
||||||
|
id="createMenu"
|
||||||
|
class="dropdown-menu"
|
||||||
|
style="
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
min-width: 140px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file" style="padding:8px 12px; cursor:pointer;">
|
||||||
|
${t('create_file')}
|
||||||
|
</li>
|
||||||
|
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder" style="padding:8px 12px; cursor:pointer;">
|
||||||
|
${t('create_folder')}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!-- Create File Modal -->
|
||||||
|
<div id="createFileModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h4 data-i18n-key="create_new_file">Create New File</h4>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="createFileNameInput"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Enter filename…"
|
||||||
|
data-i18n-placeholder="newfile_placeholder"
|
||||||
|
/>
|
||||||
|
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
|
||||||
|
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||||
|
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="downloadZipModal" class="modal" style="display:none;">
|
<div id="downloadZipModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
|
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
|
||||||
@@ -443,8 +492,7 @@
|
|||||||
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
||||||
<div id="changePasswordModal" class="modal" style="display:none;">
|
<div id="changePasswordModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content" style="max-width:400px; margin:auto;">
|
<div class="modal-content" style="max-width:400px; margin:auto;">
|
||||||
<span id="closeChangePasswordModal"
|
<span id="closeChangePasswordModal" class="editor-close-btn">×</span>
|
||||||
class="editor-close-btn">×</span>
|
|
||||||
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
||||||
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
||||||
placeholder="Old Password" style="width:100%; margin: 5px 0;" />
|
placeholder="Old Password" style="width:100%; margin: 5px 0;" />
|
||||||
@@ -462,15 +510,15 @@
|
|||||||
<form id="addUserForm">
|
<form id="addUserForm">
|
||||||
<label for="newUsername" data-i18n-key="username">Username:</label>
|
<label for="newUsername" data-i18n-key="username">Username:</label>
|
||||||
<input type="text" id="newUsername" class="form-control" required />
|
<input type="text" id="newUsername" class="form-control" required />
|
||||||
|
|
||||||
<label for="addUserPassword" data-i18n-key="password">Password:</label>
|
<label for="addUserPassword" data-i18n-key="password">Password:</label>
|
||||||
<input type="password" id="addUserPassword" class="form-control" required />
|
<input type="password" id="addUserPassword" class="form-control" required />
|
||||||
|
|
||||||
<div id="adminCheckboxContainer">
|
<div id="adminCheckboxContainer">
|
||||||
<input type="checkbox" id="isAdmin" />
|
<input type="checkbox" id="isAdmin" />
|
||||||
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
|
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-container">
|
<div class="button-container">
|
||||||
<!-- Cancel stays type="button" -->
|
<!-- Cancel stays type="button" -->
|
||||||
<button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">
|
<button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,16 +15,17 @@ import {
|
|||||||
openUserPanel,
|
openUserPanel,
|
||||||
openTOTPModal,
|
openTOTPModal,
|
||||||
closeTOTPModal,
|
closeTOTPModal,
|
||||||
setLastLoginData
|
setLastLoginData,
|
||||||
|
openApiModal
|
||||||
} from './authModals.js';
|
} from './authModals.js';
|
||||||
import { openAdminPanel } from './adminPanel.js';
|
import { openAdminPanel } from './adminPanel.js';
|
||||||
import { initializeApp } from './main.js';
|
import { initializeApp, triggerLogout } from './main.js';
|
||||||
|
|
||||||
// Production OIDC configuration (override via API as needed)
|
// Production OIDC configuration (override via API as needed)
|
||||||
const currentOIDCConfig = {
|
const currentOIDCConfig = {
|
||||||
providerUrl: "https://your-oidc-provider.com",
|
providerUrl: "https://your-oidc-provider.com",
|
||||||
clientId: "YOUR_CLIENT_ID",
|
clientId: "",
|
||||||
clientSecret: "YOUR_CLIENT_SECRET",
|
clientSecret: "",
|
||||||
redirectUri: "https://yourdomain.com/api/auth/auth.php?oidc=callback",
|
redirectUri: "https://yourdomain.com/api/auth/auth.php?oidc=callback",
|
||||||
globalOtpauthUrl: ""
|
globalOtpauthUrl: ""
|
||||||
};
|
};
|
||||||
@@ -154,7 +155,7 @@ function updateLoginOptionsUIFromStorage() {
|
|||||||
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
|
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
|
||||||
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
|
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
|
||||||
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true",
|
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true",
|
||||||
authBypass: localStorage.getItem("authBypass") === "true"
|
authBypass: localStorage.getItem("authBypass") === "true"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,21 +200,48 @@ function insertAfter(newNode, referenceNode) {
|
|||||||
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
|
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAuthenticatedUI(data) {
|
async function fetchProfilePicture() {
|
||||||
document.getElementById('loadingOverlay').remove();
|
try {
|
||||||
|
const res = await fetch('/api/profile/getCurrentUser.php', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const info = await res.json();
|
||||||
|
let pic = info.profile_picture || '';
|
||||||
|
// --- take only what's after the *last* colon ---
|
||||||
|
const parts = pic.split(':');
|
||||||
|
pic = parts[parts.length - 1] || '';
|
||||||
|
// strip any stray leading colons
|
||||||
|
pic = pic.replace(/^:+/, '');
|
||||||
|
// ensure exactly one leading slash
|
||||||
|
if (pic && !pic.startsWith('/')) pic = '/' + pic;
|
||||||
|
return pic;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('fetchProfilePicture failed:', e);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// show the wrapper (so the login form can be visible)
|
export async function updateAuthenticatedUI(data) {
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
// Save latest auth data for later reuse
|
||||||
document.getElementById('loginForm').style.display = 'none';
|
window.__lastAuthData = data;
|
||||||
|
|
||||||
|
// 1) Remove loading overlay safely
|
||||||
|
const loading = document.getElementById('loadingOverlay');
|
||||||
|
if (loading) loading.remove();
|
||||||
|
|
||||||
|
// 2) Show main UI
|
||||||
|
document.querySelector('.main-wrapper').style.display = '';
|
||||||
|
document.getElementById('loginForm').style.display = 'none';
|
||||||
toggleVisibility("loginForm", false);
|
toggleVisibility("loginForm", false);
|
||||||
toggleVisibility("mainOperations", true);
|
toggleVisibility("mainOperations", true);
|
||||||
toggleVisibility("uploadFileForm", true);
|
toggleVisibility("uploadFileForm", true);
|
||||||
toggleVisibility("fileListContainer", true);
|
toggleVisibility("fileListContainer", true);
|
||||||
//attachEnterKeyListener("addUserModal", "saveUserBtn");
|
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn");
|
||||||
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
|
|
||||||
document.querySelector(".header-buttons").style.visibility = "visible";
|
document.querySelector(".header-buttons").style.visibility = "visible";
|
||||||
|
|
||||||
|
// 3) Persist auth flags (unchanged)
|
||||||
if (typeof data.totp_enabled !== "undefined") {
|
if (typeof data.totp_enabled !== "undefined") {
|
||||||
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
||||||
}
|
}
|
||||||
@@ -221,64 +249,156 @@ function updateAuthenticatedUI(data) {
|
|||||||
localStorage.setItem("username", data.username);
|
localStorage.setItem("username", data.username);
|
||||||
}
|
}
|
||||||
if (typeof data.folderOnly !== "undefined") {
|
if (typeof data.folderOnly !== "undefined") {
|
||||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||||
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
||||||
localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
|
localStorage.setItem("disableUpload",data.disableUpload? "true" : "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage
|
||||||
|
const profilePicUrl = await fetchProfilePicture();
|
||||||
|
localStorage.setItem("profilePicUrl", profilePicUrl);
|
||||||
|
|
||||||
|
// 5) Build / update header buttons
|
||||||
const headerButtons = document.querySelector(".header-buttons");
|
const headerButtons = document.querySelector(".header-buttons");
|
||||||
const firstButton = headerButtons.firstElementChild;
|
const firstButton = headerButtons.firstElementChild;
|
||||||
|
|
||||||
|
// a) restore-from-trash for admins
|
||||||
if (data.isAdmin) {
|
if (data.isAdmin) {
|
||||||
let restoreBtn = document.getElementById("restoreFilesBtn");
|
let r = document.getElementById("restoreFilesBtn");
|
||||||
if (!restoreBtn) {
|
if (!r) {
|
||||||
restoreBtn = document.createElement("button");
|
r = document.createElement("button");
|
||||||
restoreBtn.id = "restoreFilesBtn";
|
r.id = "restoreFilesBtn";
|
||||||
restoreBtn.classList.add("btn", "btn-warning");
|
r.classList.add("btn","btn-warning");
|
||||||
restoreBtn.setAttribute("data-i18n-title", "trash_restore_delete");
|
r.setAttribute("data-i18n-title","trash_restore_delete");
|
||||||
restoreBtn.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
||||||
if (firstButton) insertAfter(restoreBtn, firstButton);
|
if (firstButton) insertAfter(r, firstButton);
|
||||||
else headerButtons.appendChild(restoreBtn);
|
else headerButtons.appendChild(r);
|
||||||
}
|
|
||||||
restoreBtn.style.display = "block";
|
|
||||||
|
|
||||||
let adminPanelBtn = document.getElementById("adminPanelBtn");
|
|
||||||
if (!adminPanelBtn) {
|
|
||||||
adminPanelBtn = document.createElement("button");
|
|
||||||
adminPanelBtn.id = "adminPanelBtn";
|
|
||||||
adminPanelBtn.classList.add("btn", "btn-info");
|
|
||||||
adminPanelBtn.setAttribute("data-i18n-title", "admin_panel");
|
|
||||||
adminPanelBtn.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
|
||||||
insertAfter(adminPanelBtn, restoreBtn);
|
|
||||||
adminPanelBtn.addEventListener("click", openAdminPanel);
|
|
||||||
} else {
|
|
||||||
adminPanelBtn.style.display = "block";
|
|
||||||
}
|
}
|
||||||
|
r.style.display = "block";
|
||||||
} else {
|
} else {
|
||||||
const restoreBtn = document.getElementById("restoreFilesBtn");
|
const r = document.getElementById("restoreFilesBtn");
|
||||||
if (restoreBtn) restoreBtn.style.display = "none";
|
if (r) r.style.display = "none";
|
||||||
const adminPanelBtn = document.getElementById("adminPanelBtn");
|
|
||||||
if (adminPanelBtn) adminPanelBtn.style.display = "none";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.location.hostname !== "demo.filerise.net") {
|
// b) admin panel button only on demo.filerise.net
|
||||||
let userPanelBtn = document.getElementById("userPanelBtn");
|
if (data.isAdmin && window.location.hostname === "demo.filerise.net") {
|
||||||
if (!userPanelBtn) {
|
let a = document.getElementById("adminPanelBtn");
|
||||||
userPanelBtn = document.createElement("button");
|
if (!a) {
|
||||||
userPanelBtn.id = "userPanelBtn";
|
a = document.createElement("button");
|
||||||
userPanelBtn.classList.add("btn", "btn-user");
|
a.id = "adminPanelBtn";
|
||||||
userPanelBtn.setAttribute("data-i18n-title", "user_panel");
|
a.classList.add("btn","btn-info");
|
||||||
userPanelBtn.innerHTML = '<i class="material-icons">account_circle</i>';
|
a.setAttribute("data-i18n-title","admin_panel");
|
||||||
|
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
||||||
|
insertAfter(a, document.getElementById("restoreFilesBtn"));
|
||||||
|
a.addEventListener("click", openAdminPanel);
|
||||||
|
}
|
||||||
|
a.style.display = "block";
|
||||||
|
} else {
|
||||||
|
const a = document.getElementById("adminPanelBtn");
|
||||||
|
if (a) a.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// c) user dropdown on non-demo
|
||||||
|
if (window.location.hostname !== "demo.filerise.net") {
|
||||||
|
let dd = document.getElementById("userDropdown");
|
||||||
|
|
||||||
|
// choose icon *or* img
|
||||||
|
const avatarHTML = profilePicUrl
|
||||||
|
? `<img src="${profilePicUrl}" style="width:24px;height:24px;border-radius:50%;vertical-align:middle;">`
|
||||||
|
: `<i class="material-icons">account_circle</i>`;
|
||||||
|
|
||||||
|
// fallback username if missing
|
||||||
|
const usernameText = data.username
|
||||||
|
|| localStorage.getItem("username")
|
||||||
|
|| "";
|
||||||
|
|
||||||
|
if (!dd) {
|
||||||
|
dd = document.createElement("div");
|
||||||
|
dd.id = "userDropdown";
|
||||||
|
dd.classList.add("user-dropdown");
|
||||||
|
|
||||||
|
// toggle button
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.id = "userDropdownToggle";
|
||||||
|
toggle.classList.add("btn","btn-user");
|
||||||
|
toggle.setAttribute("title", t("user_settings"));
|
||||||
|
toggle.innerHTML = `
|
||||||
|
${avatarHTML}
|
||||||
|
<span class="dropdown-username">${usernameText}</span>
|
||||||
|
<span class="dropdown-caret"></span>
|
||||||
|
`;
|
||||||
|
dd.append(toggle);
|
||||||
|
|
||||||
|
// menu
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.classList.add("user-menu");
|
||||||
|
menu.innerHTML = `
|
||||||
|
<div class="item" id="menuUserPanel">
|
||||||
|
<i class="material-icons folder-icon">person</i> ${t("user_panel")}
|
||||||
|
</div>
|
||||||
|
${data.isAdmin ? `
|
||||||
|
<div class="item" id="menuAdminPanel">
|
||||||
|
<i class="material-icons folder-icon">admin_panel_settings</i> ${t("admin_panel")}
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="item" id="menuApiDocs">
|
||||||
|
<i class="material-icons folder-icon">description</i> ${t("api_docs")}
|
||||||
|
</div>
|
||||||
|
<div class="item" id="menuLogout">
|
||||||
|
<i class="material-icons folder-icon">logout</i> ${t("logout")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
dd.append(menu);
|
||||||
|
|
||||||
|
// insert
|
||||||
|
const dm = document.getElementById("darkModeToggle");
|
||||||
|
if (dm) insertAfter(dd, dm);
|
||||||
|
else if (firstButton) insertAfter(dd, firstButton);
|
||||||
|
else headerButtons.appendChild(dd);
|
||||||
|
|
||||||
|
// open/close
|
||||||
|
toggle.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
menu.classList.toggle("show");
|
||||||
|
});
|
||||||
|
document.addEventListener("click", () => menu.classList.remove("show"));
|
||||||
|
|
||||||
|
// actions
|
||||||
|
document.getElementById("menuUserPanel")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
menu.classList.remove("show");
|
||||||
|
openUserPanel();
|
||||||
|
});
|
||||||
|
if (data.isAdmin) {
|
||||||
|
document.getElementById("menuAdminPanel")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
menu.classList.remove("show");
|
||||||
|
openAdminPanel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById("menuApiDocs")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
menu.classList.remove("show");
|
||||||
|
openApiModal();
|
||||||
|
});
|
||||||
|
document.getElementById("menuLogout")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
menu.classList.remove("show");
|
||||||
|
triggerLogout();
|
||||||
|
});
|
||||||
|
|
||||||
const adminBtn = document.getElementById("adminPanelBtn");
|
|
||||||
if (adminBtn) insertAfter(userPanelBtn, adminBtn);
|
|
||||||
else if (firstButton) insertAfter(userPanelBtn, firstButton);
|
|
||||||
else headerButtons.appendChild(userPanelBtn);
|
|
||||||
userPanelBtn.addEventListener("click", openUserPanel);
|
|
||||||
} else {
|
} else {
|
||||||
userPanelBtn.style.display = "block";
|
// update avatar & username only
|
||||||
|
const tog = dd.querySelector("#userDropdownToggle");
|
||||||
|
tog.innerHTML = `
|
||||||
|
${avatarHTML}
|
||||||
|
<span class="dropdown-username">${usernameText}</span>
|
||||||
|
<span class="dropdown-caret"></span>
|
||||||
|
`;
|
||||||
|
dd.style.display = "inline-block";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6) Finalize
|
||||||
initializeApp();
|
initializeApp();
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
updateItemsPerPageSelect();
|
updateItemsPerPageSelect();
|
||||||
@@ -289,7 +409,8 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
return sendRequest("/api/auth/checkAuth.php")
|
return sendRequest("/api/auth/checkAuth.php")
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.setup) {
|
if (data.setup) {
|
||||||
document.getElementById('loadingOverlay').remove();
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
|
||||||
// show the wrapper (so the login form can be visible)
|
// show the wrapper (so the login form can be visible)
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
document.querySelector('.main-wrapper').style.display = '';
|
||||||
@@ -322,13 +443,14 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
updateAuthenticatedUI(data);
|
updateAuthenticatedUI(data);
|
||||||
return data;
|
return data;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('loadingOverlay').remove();
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
|
||||||
// show the wrapper (so the login form can be visible)
|
// show the wrapper (so the login form can be visible)
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
document.querySelector('.main-wrapper').style.display = '';
|
||||||
document.getElementById('loginForm').style.display = '';
|
document.getElementById('loginForm').style.display = '';
|
||||||
if (showLoginToast) showToast("Please log in to continue.");
|
if (showLoginToast) showToast("Please log in to continue.");
|
||||||
toggleVisibility("loginForm", ! (localStorage.getItem("authBypass")==="true"));
|
toggleVisibility("loginForm", !(localStorage.getItem("authBypass") === "true"));
|
||||||
toggleVisibility("mainOperations", false);
|
toggleVisibility("mainOperations", false);
|
||||||
toggleVisibility("uploadFileForm", false);
|
toggleVisibility("uploadFileForm", false);
|
||||||
toggleVisibility("fileListContainer", false);
|
toggleVisibility("fileListContainer", false);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||||
import { sendRequest } from './networkUtils.js';
|
import { sendRequest } from './networkUtils.js';
|
||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||||
import { loadAdminConfigFunc } from './auth.js';
|
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js';
|
||||||
|
|
||||||
|
|
||||||
let lastLoginData = null;
|
let lastLoginData = null;
|
||||||
export function setLastLoginData(data) {
|
export function setLastLoginData(data) {
|
||||||
@@ -60,14 +59,11 @@ export function openTOTPLoginModal() {
|
|||||||
const totpSection = document.getElementById("totpSection");
|
const totpSection = document.getElementById("totpSection");
|
||||||
const recoverySection = document.getElementById("recoverySection");
|
const recoverySection = document.getElementById("recoverySection");
|
||||||
const toggleLink = this;
|
const toggleLink = this;
|
||||||
|
|
||||||
if (recoverySection.style.display === "none") {
|
if (recoverySection.style.display === "none") {
|
||||||
// Switch to recovery
|
|
||||||
totpSection.style.display = "none";
|
totpSection.style.display = "none";
|
||||||
recoverySection.style.display = "block";
|
recoverySection.style.display = "block";
|
||||||
toggleLink.textContent = t("use_totp_code_instead");
|
toggleLink.textContent = t("use_totp_code_instead");
|
||||||
} else {
|
} else {
|
||||||
// Switch back to TOTP
|
|
||||||
recoverySection.style.display = "none";
|
recoverySection.style.display = "none";
|
||||||
totpSection.style.display = "block";
|
totpSection.style.display = "block";
|
||||||
toggleLink.textContent = t("use_recovery_code_instead");
|
toggleLink.textContent = t("use_recovery_code_instead");
|
||||||
@@ -93,7 +89,6 @@ export function openTOTPLoginModal() {
|
|||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(json => {
|
.then(json => {
|
||||||
if (json.status === "ok") {
|
if (json.status === "ok") {
|
||||||
// recovery succeeded → finalize login
|
|
||||||
window.location.href = "/index.html";
|
window.location.href = "/index.html";
|
||||||
} else {
|
} else {
|
||||||
showToast(json.message || t("recovery_code_verification_failed"));
|
showToast(json.message || t("recovery_code_verification_failed"));
|
||||||
@@ -107,17 +102,11 @@ export function openTOTPLoginModal() {
|
|||||||
// TOTP submission
|
// TOTP submission
|
||||||
const totpInput = document.getElementById("totpLoginInput");
|
const totpInput = document.getElementById("totpLoginInput");
|
||||||
totpInput.focus();
|
totpInput.focus();
|
||||||
|
|
||||||
totpInput.addEventListener("input", async function () {
|
totpInput.addEventListener("input", async function () {
|
||||||
const code = this.value.trim();
|
const code = this.value.trim();
|
||||||
if (code.length !== 6) {
|
if (code.length !== 6) return;
|
||||||
|
|
||||||
return;
|
const tokenRes = await fetch("/api/auth/token.php", { credentials: "include" });
|
||||||
}
|
|
||||||
|
|
||||||
const tokenRes = await fetch("/api/auth/token.php", {
|
|
||||||
credentials: "include"
|
|
||||||
});
|
|
||||||
if (!tokenRes.ok) {
|
if (!tokenRes.ok) {
|
||||||
showToast(t("totp_verification_failed"));
|
showToast(t("totp_verification_failed"));
|
||||||
return;
|
return;
|
||||||
@@ -144,7 +133,6 @@ export function openTOTPLoginModal() {
|
|||||||
} else {
|
} else {
|
||||||
showToast(t("totp_verification_failed"));
|
showToast(t("totp_verification_failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.value = "";
|
this.value = "";
|
||||||
totpLoginModal.style.display = "flex";
|
totpLoginModal.style.display = "flex";
|
||||||
this.focus();
|
this.focus();
|
||||||
@@ -160,153 +148,279 @@ export function openTOTPLoginModal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openUserPanel() {
|
/**
|
||||||
const username = localStorage.getItem("username") || "User";
|
* Fetch current user info (username, profile_picture, totp_enabled)
|
||||||
let userPanelModal = document.getElementById("userPanelModal");
|
*/
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
async function fetchCurrentUser() {
|
||||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
try {
|
||||||
const modalContentStyles = `
|
const res = await fetch('/api/profile/getCurrentUser.php', {
|
||||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
credentials: 'include'
|
||||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('fetchCurrentUser failed:', e);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize any profile‐picture URL:
|
||||||
|
* - strip leading colons
|
||||||
|
* - ensure exactly one leading slash
|
||||||
|
*/
|
||||||
|
function normalizePicUrl(raw) {
|
||||||
|
if (!raw) return '';
|
||||||
|
// take only what's after the last colon
|
||||||
|
const parts = raw.split(':');
|
||||||
|
let pic = parts[parts.length - 1];
|
||||||
|
// strip any stray colons
|
||||||
|
pic = pic.replace(/^:+/, '');
|
||||||
|
// ensure leading slash
|
||||||
|
if (pic && !pic.startsWith('/')) pic = '/' + pic;
|
||||||
|
return pic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openUserPanel() {
|
||||||
|
// 1) load data
|
||||||
|
const { username = 'User', profile_picture = '', totp_enabled = false } = await fetchCurrentUser();
|
||||||
|
const raw = profile_picture;
|
||||||
|
const picUrl = normalizePicUrl(raw) || '/assets/default-avatar.png';
|
||||||
|
|
||||||
|
// 2) dark‐mode helpers
|
||||||
|
const isDark = document.body.classList.contains('dark-mode');
|
||||||
|
const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)';
|
||||||
|
const contentStyle = `
|
||||||
|
background: ${isDark ? '#2c2c2c' : '#fff'};
|
||||||
|
color: ${isDark ? '#e0e0e0' : '#000'};
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
max-width: 600px;
|
max-width: 600px; width:90%;
|
||||||
width: 90%;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow-y: auto;
|
overflow-y: auto; max-height: 500px;
|
||||||
overflow-x: hidden;
|
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
|
||||||
max-height: 383px !important;
|
|
||||||
flex-shrink: 0 !important;
|
|
||||||
scrollbar-gutter: stable both-edges;
|
|
||||||
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: none;
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
`;
|
`;
|
||||||
const savedLanguage = localStorage.getItem("language") || "en";
|
|
||||||
|
|
||||||
if (!userPanelModal) {
|
// 3) create or reuse modal
|
||||||
userPanelModal = document.createElement("div");
|
let modal = document.getElementById('userPanelModal');
|
||||||
userPanelModal.id = "userPanelModal";
|
if (!modal) {
|
||||||
userPanelModal.style.cssText = `
|
// overlay
|
||||||
position: fixed;
|
modal = document.createElement('div');
|
||||||
top: 0; right: 0; bottom: 0; left: 0;
|
modal.id = 'userPanelModal';
|
||||||
background-color: ${overlayBackground};
|
Object.assign(modal.style, {
|
||||||
display: flex;
|
position: 'fixed',
|
||||||
justify-content: center;
|
top: '0',
|
||||||
align-items: center;
|
left: '0',
|
||||||
z-index: 1000;
|
right: '0',
|
||||||
overflow: hidden;
|
bottom: '0',
|
||||||
|
background: overlayBg,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: '1000',
|
||||||
|
});
|
||||||
|
|
||||||
|
// content container
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'modal-content';
|
||||||
|
content.style.cssText = contentStyle;
|
||||||
|
|
||||||
|
// close button
|
||||||
|
const closeBtn = document.createElement('span');
|
||||||
|
closeBtn.id = 'closeUserPanel';
|
||||||
|
closeBtn.className = 'editor-close-btn';
|
||||||
|
closeBtn.textContent = '×';
|
||||||
|
closeBtn.addEventListener('click', () => modal.style.display = 'none');
|
||||||
|
content.appendChild(closeBtn);
|
||||||
|
|
||||||
|
// avatar + picker
|
||||||
|
const avatarWrapper = document.createElement('div');
|
||||||
|
avatarWrapper.style.cssText = 'text-align:center; margin-bottom:20px;';
|
||||||
|
const avatarInner = document.createElement('div');
|
||||||
|
avatarInner.style.cssText = 'position:relative; width:80px; height:80px; margin:0 auto;';
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.id = 'profilePicPreview';
|
||||||
|
img.src = picUrl;
|
||||||
|
img.alt = 'Profile Picture';
|
||||||
|
img.style.cssText = 'width:100%; height:100%; border-radius:50%; object-fit:cover;';
|
||||||
|
avatarInner.appendChild(img);
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.htmlFor = 'profilePicInput';
|
||||||
|
label.style.cssText = `
|
||||||
|
position:absolute; bottom:0; right:0;
|
||||||
|
width:24px; height:24px;
|
||||||
|
background:rgba(0,0,0,0.6);
|
||||||
|
border-radius:50%; display:flex;
|
||||||
|
align-items:center; justify-content:center;
|
||||||
|
cursor:pointer;
|
||||||
`;
|
`;
|
||||||
userPanelModal.innerHTML = `
|
const editIcon = document.createElement('i');
|
||||||
<div class="modal-content user-panel-content" style="${modalContentStyles}">
|
editIcon.className = 'material-icons';
|
||||||
<span id="closeUserPanel" class="editor-close-btn">×</span>
|
editIcon.style.cssText = 'color:#fff; font-size:16px;';
|
||||||
<h3>${t("user_panel")} (${username})</h3>
|
editIcon.textContent = 'edit';
|
||||||
|
label.appendChild(editIcon);
|
||||||
|
avatarInner.appendChild(label);
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.id = 'profilePicInput';
|
||||||
|
fileInput.accept = 'image/*';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
avatarInner.appendChild(fileInput);
|
||||||
|
avatarWrapper.appendChild(avatarInner);
|
||||||
|
content.appendChild(avatarWrapper);
|
||||||
|
|
||||||
<button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;">
|
// title
|
||||||
${t("change_password")}
|
const title = document.createElement('h3');
|
||||||
</button>
|
title.style.cssText = 'text-align:center; margin-bottom:20px;';
|
||||||
|
title.textContent = `${t('user_panel')} (${username})`;
|
||||||
|
content.appendChild(title);
|
||||||
|
|
||||||
<fieldset style="margin-bottom: 15px;">
|
// change password btn
|
||||||
<legend>${t("totp_settings")}</legend>
|
const pwdBtn = document.createElement('button');
|
||||||
<div class="form-group">
|
pwdBtn.id = 'openChangePasswordModalBtn';
|
||||||
<label for="userTOTPEnabled">${t("enable_totp")}:</label>
|
pwdBtn.className = 'btn btn-primary';
|
||||||
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" />
|
pwdBtn.style.marginBottom = '15px';
|
||||||
</div>
|
pwdBtn.textContent = t('change_password');
|
||||||
</fieldset>
|
pwdBtn.addEventListener('click', () => {
|
||||||
|
document.getElementById('changePasswordModal').style.display = 'block';
|
||||||
<fieldset style="margin-bottom: 15px;">
|
|
||||||
<legend>${t("language")}</legend>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="languageSelector">${t("select_language")}:</label>
|
|
||||||
<select id="languageSelector">
|
|
||||||
<option value="en">${t("english")}</option>
|
|
||||||
<option value="es">${t("spanish")}</option>
|
|
||||||
<option value="fr">${t("french")}</option>
|
|
||||||
<option value="de">${t("german")}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<!-- New API Docs link -->
|
|
||||||
<div style="margin-bottom: 15px;">
|
|
||||||
<button type="button" id="openApiModalBtn" class="btn btn-secondary">
|
|
||||||
${t("api_docs") || "API Docs"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(userPanelModal);
|
|
||||||
|
|
||||||
const apiModal = document.createElement("div");
|
|
||||||
apiModal.id = "apiModal";
|
|
||||||
apiModal.style.cssText = `
|
|
||||||
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
|
||||||
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
|
|
||||||
align-items: center; justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// api.php
|
|
||||||
apiModal.innerHTML = `
|
|
||||||
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
|
|
||||||
<div class="editor-close-btn" id="closeApiModal">×</div>
|
|
||||||
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(apiModal);
|
|
||||||
|
|
||||||
document.getElementById("openApiModalBtn").addEventListener("click", () => {
|
|
||||||
apiModal.style.display = "flex";
|
|
||||||
});
|
|
||||||
document.getElementById("closeApiModal").addEventListener("click", () => {
|
|
||||||
apiModal.style.display = "none";
|
|
||||||
});
|
});
|
||||||
|
content.appendChild(pwdBtn);
|
||||||
|
|
||||||
// Handlers…
|
// TOTP fieldset
|
||||||
document.getElementById("closeUserPanel").addEventListener("click", () => {
|
const totpFs = document.createElement('fieldset');
|
||||||
userPanelModal.style.display = "none";
|
totpFs.style.marginBottom = '15px';
|
||||||
});
|
const totpLegend = document.createElement('legend');
|
||||||
document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => {
|
totpLegend.textContent = t('totp_settings');
|
||||||
document.getElementById("changePasswordModal").style.display = "block";
|
totpFs.appendChild(totpLegend);
|
||||||
});
|
const totpLabel = document.createElement('label');
|
||||||
|
totpLabel.style.cursor = 'pointer';
|
||||||
|
const totpCb = document.createElement('input');
|
||||||
// TOTP checkbox
|
totpCb.type = 'checkbox';
|
||||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
totpCb.id = 'userTOTPEnabled';
|
||||||
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
|
totpCb.style.verticalAlign = 'middle';
|
||||||
totpCheckbox.addEventListener("change", function () {
|
totpCb.checked = totp_enabled;
|
||||||
localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false");
|
totpCb.addEventListener('change', async function () {
|
||||||
fetch("/api/updateUserPanel.php", {
|
const resp = await fetch('/api/updateUserPanel.php', {
|
||||||
method: "POST",
|
method: 'POST', credentials: 'include',
|
||||||
credentials: "include",
|
headers: {
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.csrfToken
|
||||||
|
},
|
||||||
body: JSON.stringify({ totp_enabled: this.checked })
|
body: JSON.stringify({ totp_enabled: this.checked })
|
||||||
})
|
});
|
||||||
.then(r => r.json())
|
const js = await resp.json();
|
||||||
.then(result => {
|
if (!js.success) showToast(js.error || t('error_updating_totp_setting'));
|
||||||
if (!result.success) showToast(t("error_updating_totp_setting") + ": " + result.error);
|
else if (this.checked) openTOTPModal();
|
||||||
else if (this.checked) openTOTPModal();
|
|
||||||
})
|
|
||||||
.catch(() => showToast(t("error_updating_totp_setting")));
|
|
||||||
});
|
});
|
||||||
|
totpLabel.appendChild(totpCb);
|
||||||
|
totpLabel.append(` ${t('enable_totp')}`);
|
||||||
|
totpFs.appendChild(totpLabel);
|
||||||
|
content.appendChild(totpFs);
|
||||||
|
|
||||||
// Language selector
|
// language fieldset
|
||||||
const languageSelector = document.getElementById("languageSelector");
|
const langFs = document.createElement('fieldset');
|
||||||
languageSelector.value = savedLanguage;
|
langFs.style.marginBottom = '15px';
|
||||||
languageSelector.addEventListener("change", function () {
|
const langLegend = document.createElement('legend');
|
||||||
localStorage.setItem("language", this.value);
|
langLegend.textContent = t('language');
|
||||||
|
langFs.appendChild(langLegend);
|
||||||
|
const langSel = document.createElement('select');
|
||||||
|
langSel.id = 'languageSelector';
|
||||||
|
langSel.className = 'form-select';
|
||||||
|
['en', 'es', 'fr', 'de'].forEach(code => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = code;
|
||||||
|
opt.textContent = t(code === 'en' ? 'english' : code === 'es' ? 'spanish' : code === 'fr' ? 'french' : 'german');
|
||||||
|
langSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
langSel.value = localStorage.getItem('language') || 'en';
|
||||||
|
langSel.addEventListener('change', function () {
|
||||||
|
localStorage.setItem('language', this.value);
|
||||||
setLocale(this.value);
|
setLocale(this.value);
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
});
|
});
|
||||||
|
langFs.appendChild(langSel);
|
||||||
|
content.appendChild(langFs);
|
||||||
|
|
||||||
|
// --- Display fieldset: “Show folders above files” ---
|
||||||
|
const dispFs = document.createElement('fieldset');
|
||||||
|
dispFs.style.marginBottom = '15px';
|
||||||
|
const dispLegend = document.createElement('legend');
|
||||||
|
dispLegend.textContent = t('display');
|
||||||
|
dispFs.appendChild(dispLegend);
|
||||||
|
const dispLabel = document.createElement('label');
|
||||||
|
dispLabel.style.cursor = 'pointer';
|
||||||
|
const dispCb = document.createElement('input');
|
||||||
|
dispCb.type = 'checkbox';
|
||||||
|
dispCb.id = 'showFoldersInList';
|
||||||
|
dispCb.style.verticalAlign = 'middle';
|
||||||
|
const stored = localStorage.getItem('showFoldersInList');
|
||||||
|
dispCb.checked = stored === null ? true : stored === 'true';
|
||||||
|
dispLabel.appendChild(dispCb);
|
||||||
|
dispLabel.append(` ${t('show_folders_above_files')}`);
|
||||||
|
dispFs.appendChild(dispLabel);
|
||||||
|
content.appendChild(dispFs);
|
||||||
|
|
||||||
|
dispCb.addEventListener('change', () => {
|
||||||
|
window.showFoldersInList = dispCb.checked;
|
||||||
|
localStorage.setItem('showFoldersInList', dispCb.checked);
|
||||||
|
// re‐load the entire file list (and strip) in one go:
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
});
|
||||||
|
|
||||||
|
// wire up image‐input change
|
||||||
|
fileInput.addEventListener('change', async function () {
|
||||||
|
const f = this.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
// preview immediately
|
||||||
|
// #nosec
|
||||||
|
img.src = URL.createObjectURL(f);
|
||||||
|
const blobUrl = URL.createObjectURL(f);
|
||||||
|
// use setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts
|
||||||
|
img.setAttribute('src', encodeURI(blobUrl));
|
||||||
|
// upload
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('profile_picture', f);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/profile/uploadPicture.php', {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const js = JSON.parse(text || '{}');
|
||||||
|
if (!res.ok) {
|
||||||
|
showToast(js.error || t('error_updating_picture'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newUrl = normalizePicUrl(js.url);
|
||||||
|
img.src = newUrl;
|
||||||
|
localStorage.setItem('profilePicUrl', newUrl);
|
||||||
|
updateAuthenticatedUI(window.__lastAuthData || {});
|
||||||
|
showToast(t('profile_picture_updated'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast(t('error_updating_picture'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// finalize
|
||||||
|
modal.appendChild(content);
|
||||||
|
document.body.appendChild(modal);
|
||||||
} else {
|
} else {
|
||||||
// Update colors if already exists
|
// reuse on reopen
|
||||||
userPanelModal.style.backgroundColor = overlayBackground;
|
Object.assign(modal.style, { background: overlayBg });
|
||||||
const modalContent = userPanelModal.querySelector(".modal-content");
|
const content = modal.querySelector('.modal-content');
|
||||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
content.style.cssText = contentStyle;
|
||||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
modal.querySelector('#profilePicPreview').src = picUrl || '/assets/default-avatar.png';
|
||||||
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
|
||||||
|
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
|
||||||
|
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
userPanelModal.style.display = "flex";
|
// show
|
||||||
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRecoveryCodeModal(recoveryCode) {
|
function showRecoveryCodeModal(recoveryCode) {
|
||||||
@@ -314,26 +428,21 @@ function showRecoveryCodeModal(recoveryCode) {
|
|||||||
recoveryModal.id = "recoveryModal";
|
recoveryModal.id = "recoveryModal";
|
||||||
recoveryModal.style.cssText = `
|
recoveryModal.style.cssText = `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0; left: 0;
|
||||||
left: 0;
|
width: 100vw; height: 100vh;
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background-color: rgba(0,0,0,0.3);
|
background-color: rgba(0,0,0,0.3);
|
||||||
display: flex;
|
display: flex; justify-content: center; align-items: center;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 3200;
|
z-index: 3200;
|
||||||
`;
|
`;
|
||||||
recoveryModal.innerHTML = `
|
recoveryModal.innerHTML = `
|
||||||
<div style="background: #fff; color: #000; padding: 20px; max-width: 400px; width: 90%; border-radius: 8px; text-align: center;">
|
<div style="background:#fff; color:#000; padding:20px; max-width:400px; width:90%; border-radius:8px; text-align:center;">
|
||||||
<h3>${t("your_recovery_code")}</h3>
|
<h3>${t("your_recovery_code")}</h3>
|
||||||
<p>${t("please_save_recovery_code")}</p>
|
<p>${t("please_save_recovery_code")}</p>
|
||||||
<code style="display: block; margin: 10px 0; font-size: 20px;">${recoveryCode}</code>
|
<code style="display:block; margin:10px 0; font-size:20px;">${recoveryCode}</code>
|
||||||
<button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button>
|
<button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(recoveryModal);
|
document.body.appendChild(recoveryModal);
|
||||||
|
|
||||||
document.getElementById("closeRecoveryModal").addEventListener("click", () => {
|
document.getElementById("closeRecoveryModal").addEventListener("click", () => {
|
||||||
recoveryModal.remove();
|
recoveryModal.remove();
|
||||||
});
|
});
|
||||||
@@ -346,106 +455,54 @@ export function openTOTPModal() {
|
|||||||
const modalContentStyles = `
|
const modalContentStyles = `
|
||||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||||
padding: 20px;
|
padding: 20px; max-width:400px; width:90%; border-radius:8px; position:relative;
|
||||||
max-width: 400px;
|
|
||||||
width: 90%;
|
|
||||||
border-radius: 8px;
|
|
||||||
position: relative;
|
|
||||||
`;
|
`;
|
||||||
if (!totpModal) {
|
if (!totpModal) {
|
||||||
totpModal = document.createElement("div");
|
totpModal = document.createElement("div");
|
||||||
totpModal.id = "totpModal";
|
totpModal.id = "totpModal";
|
||||||
totpModal.style.cssText = `
|
totpModal.style.cssText = `
|
||||||
position: fixed;
|
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
||||||
top: 0;
|
background-color:${overlayBackground}; display:flex; justify-content:center; align-items:center;
|
||||||
left: 0;
|
z-index:3100;
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background-color: ${overlayBackground};
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 3100;
|
|
||||||
`;
|
`;
|
||||||
totpModal.innerHTML = `
|
totpModal.innerHTML = `
|
||||||
<div class="modal-content" style="${modalContentStyles}">
|
<div class="modal-content" style="${modalContentStyles}">
|
||||||
<span id="closeTOTPModal" class="editor-close-btn">×</span>
|
<span id="closeTOTPModal" class="editor-close-btn">×</span>
|
||||||
<h3>${t("totp_setup")}</h3>
|
<h3>${t("totp_setup")}</h3>
|
||||||
<p>${t("scan_qr_code")}</p>
|
<p>${t("scan_qr_code")}</p>
|
||||||
<!-- Create an image placeholder without the CSRF token in the src -->
|
<img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width:100%; height:auto; display:block; margin:0 auto;" />
|
||||||
<img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width: 100%; height: auto; display: block; margin: 0 auto;">
|
<br/>
|
||||||
<br/>
|
<p>${t("enter_totp_confirmation")}</p>
|
||||||
<p>${t("enter_totp_confirmation")}</p>
|
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
||||||
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
<br/><br/>
|
||||||
<br/><br/>
|
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button>
|
||||||
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button>
|
</div>
|
||||||
</div>
|
`;
|
||||||
`;
|
|
||||||
document.body.appendChild(totpModal);
|
document.body.appendChild(totpModal);
|
||||||
loadTOTPQRCode();
|
loadTOTPQRCode();
|
||||||
|
document.getElementById("closeTOTPModal").addEventListener("click", () => closeTOTPModal(true));
|
||||||
document.getElementById("closeTOTPModal").addEventListener("click", () => {
|
|
||||||
closeTOTPModal(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("confirmTOTPBtn").addEventListener("click", async function () {
|
document.getElementById("confirmTOTPBtn").addEventListener("click", async function () {
|
||||||
const code = document.getElementById("totpConfirmInput").value.trim();
|
const code = document.getElementById("totpConfirmInput").value.trim();
|
||||||
if (code.length !== 6) {
|
if (code.length !== 6) { showToast(t("please_enter_valid_code")); return; }
|
||||||
showToast(t("please_enter_valid_code"));
|
const tokenRes = await fetch("/api/auth/token.php", { credentials: "include" });
|
||||||
return;
|
if (!tokenRes.ok) { showToast(t("error_verifying_totp_code")); return; }
|
||||||
}
|
window.csrfToken = (await tokenRes.json()).csrf_token;
|
||||||
|
|
||||||
const tokenRes = await fetch("/api/auth/token.php", {
|
|
||||||
credentials: "include"
|
|
||||||
});
|
|
||||||
if (!tokenRes.ok) {
|
|
||||||
showToast(t("error_verifying_totp_code"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { csrf_token } = await tokenRes.json();
|
|
||||||
window.csrfToken = csrf_token;
|
|
||||||
|
|
||||||
const verifyRes = await fetch("/api/totp_verify.php", {
|
const verifyRes = await fetch("/api/totp_verify.php", {
|
||||||
method: "POST",
|
method: "POST", credentials: "include",
|
||||||
credentials: "include",
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ totp_code: code })
|
body: JSON.stringify({ totp_code: code })
|
||||||
});
|
});
|
||||||
|
if (!verifyRes.ok) { showToast(t("totp_verification_failed")); return; }
|
||||||
if (!verifyRes.ok) {
|
|
||||||
showToast(t("totp_verification_failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await verifyRes.json();
|
const result = await verifyRes.json();
|
||||||
if (result.status !== "ok") {
|
if (result.status !== "ok") { showToast(result.message || t("totp_verification_failed")); return; }
|
||||||
showToast(result.message || t("totp_verification_failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(t("totp_enabled_successfully"));
|
showToast(t("totp_enabled_successfully"));
|
||||||
|
|
||||||
const saveRes = await fetch("/api/totp_saveCode.php", {
|
const saveRes = await fetch("/api/totp_saveCode.php", {
|
||||||
method: "POST",
|
method: "POST", credentials: "include", headers: { "X-CSRF-Token": window.csrfToken }
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!saveRes.ok) {
|
if (!saveRes.ok) { showToast(t("error_generating_recovery_code")); closeTOTPModal(false); return; }
|
||||||
showToast(t("error_generating_recovery_code"));
|
|
||||||
closeTOTPModal(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await saveRes.json();
|
const data = await saveRes.json();
|
||||||
if (data.status === "ok" && data.recoveryCode) {
|
if (data.status === "ok" && data.recoveryCode) showRecoveryCodeModal(data.recoveryCode);
|
||||||
showRecoveryCodeModal(data.recoveryCode);
|
else showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
|
||||||
} else {
|
|
||||||
showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
|
|
||||||
}
|
|
||||||
|
|
||||||
closeTOTPModal(false);
|
closeTOTPModal(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -458,29 +515,18 @@ export function openTOTPModal() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
totpModal.style.display = "flex";
|
totpModal.style.display = "flex";
|
||||||
totpModal.style.backgroundColor = overlayBackground;
|
totpModal.style.backgroundColor = overlayBackground;
|
||||||
const modalContent = totpModal.querySelector(".modal-content");
|
const modalContent = totpModal.querySelector(".modal-content");
|
||||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||||
|
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
||||||
// Clear any previous QR code src if needed and then load it:
|
|
||||||
const qrImg = document.getElementById("totpQRCodeImage");
|
|
||||||
if (qrImg) {
|
|
||||||
qrImg.src = "";
|
|
||||||
}
|
|
||||||
loadTOTPQRCode();
|
loadTOTPQRCode();
|
||||||
|
const totpInput = document.getElementById("totpConfirmInput");
|
||||||
// Focus the input and attach enter key listener
|
if (totpInput) {
|
||||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
totpInput.value = "";
|
||||||
if (totpConfirmInput) {
|
setTimeout(() => totpInput.focus(), 100);
|
||||||
totpConfirmInput.value = "";
|
|
||||||
setTimeout(() => {
|
|
||||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
|
||||||
if (totpConfirmInput) totpConfirmInput.focus();
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
||||||
}
|
}
|
||||||
@@ -490,42 +536,31 @@ function loadTOTPQRCode() {
|
|||||||
fetch("/api/totp_setup.php", {
|
fetch("/api/totp_setup.php", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: { "X-CSRF-Token": window.csrfToken }
|
||||||
"X-CSRF-Token": window.csrfToken // Send your CSRF token here
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(res => {
|
||||||
if (!response.ok) {
|
if (!res.ok) throw new Error("Failed to fetch QR code: " + res.status);
|
||||||
throw new Error("Failed to fetch QR code. Status: " + response.status);
|
return res.blob();
|
||||||
}
|
|
||||||
return response.blob();
|
|
||||||
})
|
})
|
||||||
.then(blob => {
|
.then(blob => {
|
||||||
const imageURL = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const qrImg = document.getElementById("totpQRCodeImage");
|
document.getElementById("totpQRCodeImage").src = url;
|
||||||
if (qrImg) {
|
|
||||||
qrImg.src = imageURL;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(err => {
|
||||||
console.error("Error loading TOTP QR code:", error);
|
console.error(err);
|
||||||
showToast(t("error_loading_qr_code"));
|
showToast(t("error_loading_qr_code"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updated closeTOTPModal function with a disable parameter
|
|
||||||
export function closeTOTPModal(disable = true) {
|
export function closeTOTPModal(disable = true) {
|
||||||
const totpModal = document.getElementById("totpModal");
|
const totpModal = document.getElementById("totpModal");
|
||||||
if (totpModal) totpModal.style.display = "none";
|
if (totpModal) totpModal.style.display = "none";
|
||||||
|
|
||||||
if (disable) {
|
if (disable) {
|
||||||
// Uncheck the Enable TOTP checkbox
|
|
||||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||||||
if (totpCheckbox) {
|
if (totpCheckbox) {
|
||||||
totpCheckbox.checked = false;
|
totpCheckbox.checked = false;
|
||||||
localStorage.setItem("userTOTPEnabled", "false");
|
localStorage.setItem("userTOTPEnabled", "false");
|
||||||
}
|
}
|
||||||
// Call endpoint to remove the TOTP secret from the user's record
|
|
||||||
fetch("/api/totp_disable.php", {
|
fetch("/api/totp_disable.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -536,10 +571,36 @@ export function closeTOTPModal(disable = true) {
|
|||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (!result.success) {
|
if (!result.success) showToast(t("error_disabling_totp_setting") + ": " + result.error);
|
||||||
showToast(t("error_disabling_totp_setting") + ": " + result.error);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => { showToast(t("error_disabling_totp_setting")); });
|
.catch(() => showToast(t("error_disabling_totp_setting")));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openApiModal() {
|
||||||
|
let apiModal = document.getElementById("apiModal");
|
||||||
|
if (!apiModal) {
|
||||||
|
// create the container exactly as you do now inside openUserPanel
|
||||||
|
apiModal = document.createElement("div");
|
||||||
|
apiModal.id = "apiModal";
|
||||||
|
apiModal.style.cssText = `
|
||||||
|
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
||||||
|
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
`;
|
||||||
|
apiModal.innerHTML = `
|
||||||
|
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
|
||||||
|
<div class="editor-close-btn" id="closeApiModal">×</div>
|
||||||
|
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(apiModal);
|
||||||
|
|
||||||
|
// wire up its close button
|
||||||
|
document.getElementById("closeApiModal").addEventListener("click", () => {
|
||||||
|
apiModal.style.display = "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// finally, show it
|
||||||
|
apiModal.style.display = "flex";
|
||||||
}
|
}
|
||||||
@@ -33,54 +33,66 @@ export function toggleAllCheckboxes(masterCheckbox) {
|
|||||||
export function updateFileActionButtons() {
|
export function updateFileActionButtons() {
|
||||||
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
|
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
|
||||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||||
|
|
||||||
|
const deleteBtn = document.getElementById("deleteSelectedBtn");
|
||||||
const copyBtn = document.getElementById("copySelectedBtn");
|
const copyBtn = document.getElementById("copySelectedBtn");
|
||||||
const moveBtn = document.getElementById("moveSelectedBtn");
|
const moveBtn = document.getElementById("moveSelectedBtn");
|
||||||
const deleteBtn = document.getElementById("deleteSelectedBtn");
|
|
||||||
const zipBtn = document.getElementById("downloadZipBtn");
|
const zipBtn = document.getElementById("downloadZipBtn");
|
||||||
const extractZipBtn = document.getElementById("extractZipBtn");
|
const extractZipBtn = document.getElementById("extractZipBtn");
|
||||||
|
const createBtn = document.getElementById("createBtn");
|
||||||
|
|
||||||
// keep the “select all” in sync ——
|
const anyFiles = fileCheckboxes.length > 0;
|
||||||
const master = document.getElementById("selectAll");
|
const anySelected = selectedCheckboxes.length > 0;
|
||||||
if (master) {
|
const anyZip = Array.from(selectedCheckboxes)
|
||||||
if (selectedCheckboxes.length === fileCheckboxes.length) {
|
.some(cb => cb.value.toLowerCase().endsWith(".zip"));
|
||||||
master.checked = true;
|
|
||||||
master.indeterminate = false;
|
|
||||||
} else if (selectedCheckboxes.length === 0) {
|
|
||||||
master.checked = false;
|
|
||||||
master.indeterminate = false;
|
|
||||||
} else {
|
|
||||||
master.checked = false;
|
|
||||||
master.indeterminate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileCheckboxes.length === 0) {
|
// — Select All checkbox sync (unchanged) —
|
||||||
if (copyBtn) copyBtn.style.display = "none";
|
const master = document.getElementById("selectAll");
|
||||||
if (moveBtn) moveBtn.style.display = "none";
|
if (master) {
|
||||||
if (deleteBtn) deleteBtn.style.display = "none";
|
if (selectedCheckboxes.length === fileCheckboxes.length) {
|
||||||
if (zipBtn) zipBtn.style.display = "none";
|
master.checked = true;
|
||||||
if (extractZipBtn) extractZipBtn.style.display = "none";
|
master.indeterminate = false;
|
||||||
} else {
|
} else if (selectedCheckboxes.length === 0) {
|
||||||
if (copyBtn) copyBtn.style.display = "inline-block";
|
master.checked = false;
|
||||||
if (moveBtn) moveBtn.style.display = "inline-block";
|
master.indeterminate = false;
|
||||||
if (deleteBtn) deleteBtn.style.display = "inline-block";
|
} else {
|
||||||
if (zipBtn) zipBtn.style.display = "inline-block";
|
master.checked = false;
|
||||||
if (extractZipBtn) extractZipBtn.style.display = "inline-block";
|
master.indeterminate = true;
|
||||||
|
|
||||||
const anySelected = selectedCheckboxes.length > 0;
|
|
||||||
if (copyBtn) copyBtn.disabled = !anySelected;
|
|
||||||
if (moveBtn) moveBtn.disabled = !anySelected;
|
|
||||||
if (deleteBtn) deleteBtn.disabled = !anySelected;
|
|
||||||
if (zipBtn) zipBtn.disabled = !anySelected;
|
|
||||||
|
|
||||||
if (extractZipBtn) {
|
|
||||||
// Enable only if at least one selected file ends with .zip (case-insensitive).
|
|
||||||
const anyZipSelected = Array.from(selectedCheckboxes).some(chk =>
|
|
||||||
chk.value.toLowerCase().endsWith(".zip")
|
|
||||||
);
|
|
||||||
extractZipBtn.disabled = !anyZipSelected;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete / Copy / Move: only show when something is selected
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.style.display = anySelected ? "" : "none";
|
||||||
|
}
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.style.display = anySelected ? "" : "none";
|
||||||
|
}
|
||||||
|
if (moveBtn) {
|
||||||
|
moveBtn.style.display = anySelected ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download ZIP: only show when something is selected
|
||||||
|
if (zipBtn) {
|
||||||
|
zipBtn.style.display = anySelected ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract ZIP: only show when a selected file is a .zip
|
||||||
|
if (extractZipBtn) {
|
||||||
|
extractZipBtn.style.display = anyZip ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create File: only show when nothing is selected
|
||||||
|
if (createBtn) {
|
||||||
|
createBtn.style.display = anySelected ? "none" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally disable the ones that are shown but shouldn’t be clickable
|
||||||
|
if (deleteBtn) deleteBtn.disabled = !anySelected;
|
||||||
|
if (copyBtn) copyBtn.disabled = !anySelected;
|
||||||
|
if (moveBtn) moveBtn.disabled = !anySelected;
|
||||||
|
if (zipBtn) zipBtn.disabled = !anySelected;
|
||||||
|
if (extractZipBtn) extractZipBtn.disabled = !anyZip;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showToast(message, duration = 3000) {
|
export function showToast(message, duration = 3000) {
|
||||||
@@ -178,9 +190,14 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
||||||
}
|
}
|
||||||
previewButton = `<button class="btn btn-sm btn-info preview-btn" data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}" data-preview-name="${safeFileName}">
|
previewButton = `<button
|
||||||
${previewIcon}
|
type="button"
|
||||||
</button>`;
|
class="btn btn-sm btn-info preview-btn"
|
||||||
|
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
||||||
|
data-preview-name="${safeFileName}"
|
||||||
|
title="${t('preview')}">
|
||||||
|
${previewIcon}
|
||||||
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -194,19 +211,44 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
<td class="hide-small nowrap">${safeSize}</td>
|
<td class="hide-small nowrap">${safeSize}</td>
|
||||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;">
|
<div class="btn-group btn-group-sm" role="group" aria-label="File actions">
|
||||||
<button type="button" class="btn btn-sm btn-success download-btn" data-download-name="${file.name}" data-download-folder="${file.folder || 'root'}" title="${t('download')}">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-success download-btn"
|
||||||
|
data-download-name="${file.name}"
|
||||||
|
data-download-folder="${file.folder || 'root'}"
|
||||||
|
title="${t('download')}">
|
||||||
<i class="material-icons">file_download</i>
|
<i class="material-icons">file_download</i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
${file.editable ? `
|
${file.editable ? `
|
||||||
<button class="btn btn-sm edit-btn" data-edit-name="${file.name}" data-edit-folder="${file.folder || 'root'}" title="${t('edit')}">
|
<button
|
||||||
<i class="material-icons">edit</i>
|
type="button"
|
||||||
</button>
|
class="btn btn-sm btn-secondary edit-btn"
|
||||||
` : ""}
|
data-edit-name="${file.name}"
|
||||||
|
data-edit-folder="${file.folder || 'root'}"
|
||||||
|
title="${t('edit')}">
|
||||||
|
<i class="material-icons">edit</i>
|
||||||
|
</button>` : ""}
|
||||||
|
|
||||||
${previewButton}
|
${previewButton}
|
||||||
<button class="btn btn-sm btn-warning rename-btn" data-rename-name="${file.name}" data-rename-folder="${file.folder || 'root'}" title="${t('rename')}">
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-warning rename-btn"
|
||||||
|
data-rename-name="${file.name}"
|
||||||
|
data-rename-folder="${file.folder || 'root'}"
|
||||||
|
title="${t('rename')}">
|
||||||
<i class="material-icons">drive_file_rename_outline</i>
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- share -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm share-btn ms-1"
|
||||||
|
data-file="${safeFileName}"
|
||||||
|
title="${t('share')}">
|
||||||
|
<i class="material-icons">share</i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -32,23 +32,33 @@ export function loadSidebarOrder() {
|
|||||||
updateSidebarVisibility();
|
updateSidebarVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Load header order from localStorage.
|
|
||||||
export function loadHeaderOrder() {
|
export function loadHeaderOrder() {
|
||||||
const headerDropArea = document.getElementById('headerDropArea');
|
const headerDropArea = document.getElementById('headerDropArea');
|
||||||
if (!headerDropArea) return;
|
if (!headerDropArea) return;
|
||||||
const orderStr = localStorage.getItem('headerOrder');
|
|
||||||
if (orderStr) {
|
// 1) Clear out any icons that might already be in the drop area
|
||||||
const order = JSON.parse(orderStr);
|
headerDropArea.innerHTML = '';
|
||||||
if (order.length > 0) {
|
|
||||||
order.forEach(id => {
|
// 2) Read the saved array (or empty array if invalid/missing)
|
||||||
const card = document.getElementById(id);
|
let stored;
|
||||||
// Only load if card is not already in header drop zone.
|
try {
|
||||||
if (card && card.parentNode.id !== 'headerDropArea') {
|
stored = JSON.parse(localStorage.getItem('headerOrder') || '[]');
|
||||||
insertCardInHeader(card, null);
|
} catch {
|
||||||
}
|
stored = [];
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3) Deduplicate IDs
|
||||||
|
const uniqueIds = Array.from(new Set(stored));
|
||||||
|
|
||||||
|
// 4) Re-insert exactly one icon per saved card ID
|
||||||
|
uniqueIds.forEach(id => {
|
||||||
|
const card = document.getElementById(id);
|
||||||
|
if (card) insertCardInHeader(card, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5) Persist the cleaned, deduped list back to storage
|
||||||
|
localStorage.setItem('headerOrder', JSON.stringify(uniqueIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal helper: update sidebar visibility based on its content.
|
// Internal helper: update sidebar visibility based on its content.
|
||||||
|
|||||||
@@ -76,6 +76,72 @@ export function handleDownloadZipSelected(e) {
|
|||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function handleCreateFileSelected(e) {
|
||||||
|
e.preventDefault(); e.stopImmediatePropagation();
|
||||||
|
const modal = document.getElementById('createFileModal');
|
||||||
|
modal.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
const inp = document.getElementById('newFileCreateName');
|
||||||
|
if (inp) inp.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the “New File” modal
|
||||||
|
*/
|
||||||
|
export function openCreateFileModal() {
|
||||||
|
const modal = document.getElementById('createFileModal');
|
||||||
|
const input = document.getElementById('createFileNameInput');
|
||||||
|
if (!modal || !input) {
|
||||||
|
console.error('Create-file modal or input not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
modal.style.display = 'block';
|
||||||
|
setTimeout(() => input.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function handleCreateFile(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.getElementById('createFileNameInput');
|
||||||
|
if (!input) return console.error('Create-file input missing');
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showToast(t('newfile_placeholder')); // or a more explicit error
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = window.currentFolder || 'root';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/file/createFile.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type':'application/json',
|
||||||
|
'X-CSRF-Token': window.csrfToken
|
||||||
|
},
|
||||||
|
// ⚠️ must send `name`, not `filename`
|
||||||
|
body: JSON.stringify({ folder, name })
|
||||||
|
});
|
||||||
|
const js = await res.json();
|
||||||
|
if (!js.success) throw new Error(js.error);
|
||||||
|
showToast(t('file_created'));
|
||||||
|
loadFileList(folder);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || t('error_creating_file'));
|
||||||
|
} finally {
|
||||||
|
document.getElementById('createFileModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const cancel = document.getElementById('cancelCreateFile');
|
||||||
|
const confirm = document.getElementById('confirmCreateFile');
|
||||||
|
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
|
||||||
|
if (confirm) confirm.addEventListener('click', handleCreateFile);
|
||||||
|
});
|
||||||
|
|
||||||
export function openDownloadModal(fileName, folder) {
|
export function openDownloadModal(fileName, folder) {
|
||||||
// Store file details globally for the download confirmation function.
|
// Store file details globally for the download confirmation function.
|
||||||
window.singleFileToDownload = fileName;
|
window.singleFileToDownload = fileName;
|
||||||
@@ -197,6 +263,49 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const progressModal = document.getElementById("downloadProgressModal");
|
const progressModal = document.getElementById("downloadProgressModal");
|
||||||
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
||||||
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
||||||
|
const cancelCreate = document.getElementById('cancelCreateFile');
|
||||||
|
|
||||||
|
if (cancelCreate) {
|
||||||
|
cancelCreate.addEventListener('click', () => {
|
||||||
|
document.getElementById('createFileModal').style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmCreate = document.getElementById('confirmCreateFile');
|
||||||
|
if (confirmCreate) {
|
||||||
|
confirmCreate.addEventListener('click', async () => {
|
||||||
|
const name = document.getElementById('newFileCreateName').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showToast(t('please_enter_filename'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('createFileModal').style.display = 'none';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/file/createFile.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
folder: window.currentFolder || 'root',
|
||||||
|
filename: name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const js = await res.json();
|
||||||
|
if (!res.ok || !js.success) {
|
||||||
|
throw new Error(js.error || t('error_creating_file'));
|
||||||
|
}
|
||||||
|
showToast(t('file_created_successfully'));
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast(err.message || t('error_creating_file'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
attachEnterKeyListener('createFileModal','confirmCreateFile');
|
||||||
|
}
|
||||||
|
|
||||||
// 1) Cancel button hides the name modal
|
// 1) Cancel button hides the name modal
|
||||||
if (cancelZipBtn) {
|
if (cancelZipBtn) {
|
||||||
@@ -553,8 +662,14 @@ export function initFileActions() {
|
|||||||
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
|
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
|
||||||
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
|
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
|
||||||
}
|
}
|
||||||
|
const createBtn = document.getElementById('createFileBtn');
|
||||||
|
if (createBtn) {
|
||||||
|
createBtn.replaceWith(createBtn.cloneNode(true));
|
||||||
|
document.getElementById('createFileBtn').addEventListener('click', openCreateFileModal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Hook up the single‐file download modal buttons
|
// Hook up the single‐file download modal buttons
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile");
|
const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile");
|
||||||
@@ -573,4 +688,35 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
attachEnterKeyListener("downloadFileModal", "confirmSingleDownloadButton");
|
attachEnterKeyListener("downloadFileModal", "confirmSingleDownloadButton");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const btn = document.getElementById('createBtn');
|
||||||
|
const menu = document.getElementById('createMenu');
|
||||||
|
const fileOpt = document.getElementById('createFileOption');
|
||||||
|
const folderOpt= document.getElementById('createFolderOption');
|
||||||
|
|
||||||
|
// Toggle dropdown on click
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create File
|
||||||
|
fileOpt.addEventListener('click', () => {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
openCreateFileModal(); // your existing function
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Folder
|
||||||
|
folderOpt.addEventListener('click', () => {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
document.getElementById('createFolderModal').style.display = 'block';
|
||||||
|
document.getElementById('newFolderName').focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close if you click anywhere else
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
window.renameFile = renameFile;
|
window.renameFile = renameFile;
|
||||||
@@ -3,20 +3,143 @@ import { escapeHTML, showToast } from './domUtils.js';
|
|||||||
import { loadFileList } from './fileListView.js';
|
import { loadFileList } from './fileListView.js';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js';
|
||||||
|
|
||||||
|
// thresholds for editor behavior
|
||||||
|
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
|
||||||
|
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
|
||||||
|
|
||||||
|
// Lazy-load CodeMirror modes on demand
|
||||||
|
const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
|
||||||
|
const MODE_URL = {
|
||||||
|
// core you've likely already loaded:
|
||||||
|
"xml": "mode/xml/xml.min.js",
|
||||||
|
"css": "mode/css/css.min.js",
|
||||||
|
"javascript": "mode/javascript/javascript.min.js",
|
||||||
|
|
||||||
|
// extras you may want on-demand:
|
||||||
|
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js",
|
||||||
|
"application/x-httpd-php": "mode/php/php.min.js",
|
||||||
|
"php": "mode/php/php.min.js",
|
||||||
|
"markdown": "mode/markdown/markdown.min.js",
|
||||||
|
"python": "mode/python/python.min.js",
|
||||||
|
"sql": "mode/sql/sql.min.js",
|
||||||
|
"shell": "mode/shell/shell.min.js",
|
||||||
|
"yaml": "mode/yaml/yaml.min.js",
|
||||||
|
"properties": "mode/properties/properties.min.js",
|
||||||
|
"text/x-csrc": "mode/clike/clike.min.js",
|
||||||
|
"text/x-c++src": "mode/clike/clike.min.js",
|
||||||
|
"text/x-java": "mode/clike/clike.min.js",
|
||||||
|
"text/x-csharp": "mode/clike/clike.min.js",
|
||||||
|
"text/x-kotlin": "mode/clike/clike.min.js"
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadScriptOnce(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const key = `cm:${url}`;
|
||||||
|
let s = document.querySelector(`script[data-key="${key}"]`);
|
||||||
|
if (s) {
|
||||||
|
if (s.dataset.loaded === "1") return resolve();
|
||||||
|
s.addEventListener("load", () => resolve());
|
||||||
|
s.addEventListener("error", reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s = document.createElement("script");
|
||||||
|
s.src = url;
|
||||||
|
s.defer = true;
|
||||||
|
s.dataset.key = key;
|
||||||
|
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
|
||||||
|
s.addEventListener("error", reject);
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureModeLoaded(modeOption) {
|
||||||
|
if (!window.CodeMirror) return; // CM core must be present
|
||||||
|
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
|
||||||
|
if (!name) return;
|
||||||
|
// Already registered?
|
||||||
|
if ((CodeMirror.modes && CodeMirror.modes[name]) || (CodeMirror.mimeModes && CodeMirror.mimeModes[name])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = MODE_URL[name];
|
||||||
|
if (!url) return; // unknown -> fallback to text/plain
|
||||||
|
// Dependencies (htmlmixed needs xml/css/js; php highlighting with HTML also benefits from htmlmixed)
|
||||||
|
if (name === "htmlmixed") {
|
||||||
|
await Promise.all([
|
||||||
|
ensureModeLoaded("xml"),
|
||||||
|
ensureModeLoaded("css"),
|
||||||
|
ensureModeLoaded("javascript")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (name === "application/x-httpd-php") {
|
||||||
|
await ensureModeLoaded("htmlmixed");
|
||||||
|
}
|
||||||
|
await loadScriptOnce(CM_CDN + url);
|
||||||
|
}
|
||||||
|
|
||||||
function getModeForFile(fileName) {
|
function getModeForFile(fileName) {
|
||||||
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
|
const dot = fileName.lastIndexOf(".");
|
||||||
|
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
||||||
|
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case "css":
|
// markup
|
||||||
return "css";
|
|
||||||
case "json":
|
|
||||||
return { name: "javascript", json: true };
|
|
||||||
case "js":
|
|
||||||
return "javascript";
|
|
||||||
case "html":
|
case "html":
|
||||||
case "htm":
|
case "htm":
|
||||||
return "text/html";
|
return "text/html"; // ensureModeLoaded will map to htmlmixed
|
||||||
case "xml":
|
case "xml":
|
||||||
return "xml";
|
return "xml";
|
||||||
|
case "md":
|
||||||
|
case "markdown":
|
||||||
|
return "markdown";
|
||||||
|
case "yml":
|
||||||
|
case "yaml":
|
||||||
|
return "yaml";
|
||||||
|
|
||||||
|
// styles & scripts
|
||||||
|
case "css":
|
||||||
|
return "css";
|
||||||
|
case "js":
|
||||||
|
return "javascript";
|
||||||
|
case "json":
|
||||||
|
return { name: "javascript", json: true };
|
||||||
|
|
||||||
|
// server / langs
|
||||||
|
case "php":
|
||||||
|
return "application/x-httpd-php";
|
||||||
|
case "py":
|
||||||
|
return "python";
|
||||||
|
case "sql":
|
||||||
|
return "sql";
|
||||||
|
case "sh":
|
||||||
|
case "bash":
|
||||||
|
case "zsh":
|
||||||
|
case "bat":
|
||||||
|
return "shell";
|
||||||
|
|
||||||
|
// config-y files
|
||||||
|
case "ini":
|
||||||
|
case "conf":
|
||||||
|
case "config":
|
||||||
|
case "properties":
|
||||||
|
return "properties";
|
||||||
|
|
||||||
|
// C-family / JVM
|
||||||
|
case "c":
|
||||||
|
case "h":
|
||||||
|
return "text/x-csrc";
|
||||||
|
case "cpp":
|
||||||
|
case "cxx":
|
||||||
|
case "hpp":
|
||||||
|
case "hh":
|
||||||
|
case "hxx":
|
||||||
|
return "text/x-c++src";
|
||||||
|
case "java":
|
||||||
|
return "text/x-java";
|
||||||
|
case "cs":
|
||||||
|
return "text/x-csharp";
|
||||||
|
case "kt":
|
||||||
|
case "kts":
|
||||||
|
return "text/x-kotlin";
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return "text/plain";
|
return "text/plain";
|
||||||
}
|
}
|
||||||
@@ -47,6 +170,7 @@ export function editFile(fileName, folder) {
|
|||||||
if (existingEditor) {
|
if (existingEditor) {
|
||||||
existingEditor.remove();
|
existingEditor.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderUsed = folder || window.currentFolder || "root";
|
const folderUsed = folder || window.currentFolder || "root";
|
||||||
const folderPath = folderUsed === "root"
|
const folderPath = folderUsed === "root"
|
||||||
? "uploads/"
|
? "uploads/"
|
||||||
@@ -55,26 +179,40 @@ export function editFile(fileName, folder) {
|
|||||||
|
|
||||||
fetch(fileUrl, { method: "HEAD" })
|
fetch(fileUrl, { method: "HEAD" })
|
||||||
.then(response => {
|
.then(response => {
|
||||||
const contentLength = response.headers.get("Content-Length");
|
const lenHeader =
|
||||||
if (contentLength !== null && parseInt(contentLength) > 10485760) {
|
response.headers.get("content-length") ??
|
||||||
|
response.headers.get("Content-Length");
|
||||||
|
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||||
|
|
||||||
|
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
|
||||||
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
||||||
throw new Error("File too large.");
|
throw new Error("File too large.");
|
||||||
}
|
}
|
||||||
return fetch(fileUrl);
|
return response;
|
||||||
})
|
})
|
||||||
|
.then(() => fetch(fileUrl))
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("HTTP error! Status: " + response.status);
|
throw new Error("HTTP error! Status: " + response.status);
|
||||||
}
|
}
|
||||||
return response.text();
|
const lenHeader =
|
||||||
|
response.headers.get("content-length") ??
|
||||||
|
response.headers.get("Content-Length");
|
||||||
|
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||||
|
return Promise.all([response.text(), sizeBytes]);
|
||||||
})
|
})
|
||||||
.then(content => {
|
.then(([content, sizeBytes]) => {
|
||||||
|
const forcePlainText =
|
||||||
|
sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
|
||||||
|
|
||||||
const modal = document.createElement("div");
|
const modal = document.createElement("div");
|
||||||
modal.id = "editorContainer";
|
modal.id = "editorContainer";
|
||||||
modal.classList.add("modal", "editor-modal");
|
modal.classList.add("modal", "editor-modal");
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="editor-header">
|
<div class="editor-header">
|
||||||
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}</h3>
|
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}${
|
||||||
|
forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""
|
||||||
|
}</h3>
|
||||||
<div class="editor-controls">
|
<div class="editor-controls">
|
||||||
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
|
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
|
||||||
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
|
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
|
||||||
@@ -90,61 +228,74 @@ export function editFile(fileName, folder) {
|
|||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
modal.style.display = "block";
|
modal.style.display = "block";
|
||||||
|
|
||||||
const mode = getModeForFile(fileName);
|
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||||
const theme = isDarkMode ? "material-darker" : "default";
|
const theme = isDarkMode ? "material-darker" : "default";
|
||||||
|
|
||||||
const editor = CodeMirror.fromTextArea(document.getElementById("fileEditor"), {
|
// choose mode + lighter settings for large files
|
||||||
lineNumbers: true,
|
const mode = forcePlainText ? "text/plain" : getModeForFile(fileName);
|
||||||
|
const cmOptions = {
|
||||||
|
lineNumbers: !forcePlainText,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
viewportMargin: Infinity
|
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||||
});
|
lineWrapping: false,
|
||||||
|
};
|
||||||
|
|
||||||
window.currentEditor = editor;
|
// ✅ LOAD MODE FIRST, THEN INSTANTIATE CODEMIRROR
|
||||||
|
ensureModeLoaded(mode).finally(() => {
|
||||||
|
const editor = CodeMirror.fromTextArea(
|
||||||
|
document.getElementById("fileEditor"),
|
||||||
|
cmOptions
|
||||||
|
);
|
||||||
|
|
||||||
setTimeout(() => {
|
window.currentEditor = editor;
|
||||||
adjustEditorSize();
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
observeModalResize(modal);
|
setTimeout(() => {
|
||||||
|
adjustEditorSize();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
let currentFontSize = 14;
|
observeModalResize(modal);
|
||||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
|
||||||
editor.refresh();
|
|
||||||
|
|
||||||
document.getElementById("closeEditorX").addEventListener("click", function () {
|
let currentFontSize = 14;
|
||||||
modal.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("decreaseFont").addEventListener("click", function () {
|
|
||||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
|
||||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
editor.refresh();
|
||||||
|
|
||||||
|
document.getElementById("closeEditorX").addEventListener("click", function () {
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("decreaseFont").addEventListener("click", function () {
|
||||||
|
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||||
|
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||||
|
editor.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("increaseFont").addEventListener("click", function () {
|
||||||
|
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||||
|
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||||
|
editor.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("saveBtn").addEventListener("click", function () {
|
||||||
|
saveFile(fileName, folderUsed);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("closeBtn").addEventListener("click", function () {
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateEditorTheme() {
|
||||||
|
const isDark = document.body.classList.contains("dark-mode");
|
||||||
|
editor.setOption("theme", isDark ? "material-darker" : "default");
|
||||||
|
}
|
||||||
|
const toggle = document.getElementById("darkModeToggle");
|
||||||
|
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("increaseFont").addEventListener("click", function () {
|
|
||||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
|
||||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
|
||||||
editor.refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("saveBtn").addEventListener("click", function () {
|
|
||||||
saveFile(fileName, folderUsed);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("closeBtn").addEventListener("click", function () {
|
|
||||||
modal.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateEditorTheme() {
|
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
|
||||||
editor.setOption("theme", isDarkMode ? "material-darker" : "default");
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("darkModeToggle").addEventListener("click", updateEditorTheme);
|
|
||||||
})
|
})
|
||||||
.catch(error => console.error("Error loading file:", error));
|
.catch(error => {
|
||||||
|
if (error && error.name === "AbortError") return;
|
||||||
|
console.error("Error loading file:", error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
// fileMenu.js
|
// fileMenu.js
|
||||||
import { updateRowHighlight, showToast } from './domUtils.js';
|
import { updateRowHighlight, showToast } from './domUtils.js';
|
||||||
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile } from './fileActions.js';
|
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js';
|
||||||
import { previewFile } from './filePreview.js';
|
import { previewFile } from './filePreview.js';
|
||||||
import { editFile } from './fileEditor.js';
|
import { editFile } from './fileEditor.js';
|
||||||
import { canEditFile, fileData } from './fileListView.js';
|
import { canEditFile, fileData } from './fileListView.js';
|
||||||
@@ -75,6 +75,7 @@ export function fileListContextMenuHandler(e) {
|
|||||||
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
|
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
|
||||||
|
|
||||||
let menuItems = [
|
let menuItems = [
|
||||||
|
{ label: t("create_file"), action: () => openCreateFileModal() },
|
||||||
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
|
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
|
||||||
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
|
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
|
||||||
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
|
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ import { openFolderShareModal } from './folderShareModal.js';
|
|||||||
import { fetchWithCsrf } from './auth.js';
|
import { fetchWithCsrf } from './auth.js';
|
||||||
import { loadCsrfToken } from './main.js';
|
import { loadCsrfToken } from './main.js';
|
||||||
|
|
||||||
|
/* ----------------------
|
||||||
|
Helpers: safe JSON + state
|
||||||
|
----------------------*/
|
||||||
|
|
||||||
|
// Robust JSON reader that surfaces server errors (with status)
|
||||||
|
async function safeJson(res) {
|
||||||
|
const text = await res.text();
|
||||||
|
let body = null;
|
||||||
|
try { body = text ? JSON.parse(text) : null; } catch { /* ignore */ }
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
(body && (body.error || body.message)) ||
|
||||||
|
(text && text.trim()) ||
|
||||||
|
`HTTP ${res.status}`;
|
||||||
|
const err = new Error(msg);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return body ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
/* ----------------------
|
/* ----------------------
|
||||||
Helper Functions (Data/State)
|
Helper Functions (Data/State)
|
||||||
----------------------*/
|
----------------------*/
|
||||||
@@ -15,7 +37,7 @@ import { loadCsrfToken } from './main.js';
|
|||||||
export function formatFolderName(folder) {
|
export function formatFolderName(folder) {
|
||||||
if (typeof folder !== "string") return "";
|
if (typeof folder !== "string") return "";
|
||||||
if (folder.indexOf("/") !== -1) {
|
if (folder.indexOf("/") !== -1) {
|
||||||
let parts = folder.split("/");
|
const parts = folder.split("/");
|
||||||
let indent = "";
|
let indent = "";
|
||||||
for (let i = 1; i < parts.length; i++) {
|
for (let i = 1; i < parts.length; i++) {
|
||||||
indent += "\u00A0\u00A0\u00A0\u00A0"; // 4 non-breaking spaces per level
|
indent += "\u00A0\u00A0\u00A0\u00A0"; // 4 non-breaking spaces per level
|
||||||
@@ -34,9 +56,7 @@ function buildFolderTree(folders) {
|
|||||||
const parts = folderPath.split('/');
|
const parts = folderPath.split('/');
|
||||||
let current = tree;
|
let current = tree;
|
||||||
parts.forEach(part => {
|
parts.forEach(part => {
|
||||||
if (!current[part]) {
|
if (!current[part]) current[part] = {};
|
||||||
current[part] = {};
|
|
||||||
}
|
|
||||||
current = current[part];
|
current = current[part];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -56,7 +76,7 @@ function saveFolderTreeState(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper for getting the parent folder.
|
// Helper for getting the parent folder.
|
||||||
function getParentFolder(folder) {
|
export function getParentFolder(folder) {
|
||||||
if (folder === "root") return "root";
|
if (folder === "root") return "root";
|
||||||
const lastSlash = folder.lastIndexOf("/");
|
const lastSlash = folder.lastIndexOf("/");
|
||||||
return lastSlash === -1 ? "root" : folder.substring(0, lastSlash);
|
return lastSlash === -1 ? "root" : folder.substring(0, lastSlash);
|
||||||
@@ -66,23 +86,29 @@ function getParentFolder(folder) {
|
|||||||
Breadcrumb Functions
|
Breadcrumb Functions
|
||||||
----------------------*/
|
----------------------*/
|
||||||
|
|
||||||
function renderBreadcrumb(normalizedFolder) {
|
async function applyFolderCapabilities(folder) {
|
||||||
if (!normalizedFolder || normalizedFolder === "") return "";
|
try {
|
||||||
const parts = normalizedFolder.split("/");
|
const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' });
|
||||||
let breadcrumbItems = [];
|
if (!res.ok) return;
|
||||||
// Use the first segment as the root.
|
const caps = await res.json();
|
||||||
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${parts[0]}">${escapeHTML(parts[0])}</span>`);
|
|
||||||
let cumulative = parts[0];
|
// top buttons
|
||||||
parts.slice(1).forEach(part => {
|
const createBtn = document.getElementById('createFolderBtn');
|
||||||
cumulative += "/" + part;
|
const renameBtn = document.getElementById('renameFolderBtn');
|
||||||
breadcrumbItems.push(`<span class="breadcrumb-separator"> / </span>`);
|
const deleteBtn = document.getElementById('deleteFolderBtn');
|
||||||
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${cumulative}">${escapeHTML(part)}</span>`);
|
const shareBtn = document.getElementById('shareFolderBtn');
|
||||||
});
|
|
||||||
return breadcrumbItems.join('');
|
if (createBtn) createBtn.disabled = !caps.canCreate;
|
||||||
|
if (renameBtn) renameBtn.disabled = !caps.canRename || folder === 'root';
|
||||||
|
if (deleteBtn) deleteBtn.disabled = !caps.canDelete || folder === 'root';
|
||||||
|
if (shareBtn) shareBtn.disabled = !caps.canShare || folder === 'root';
|
||||||
|
|
||||||
|
// keep for later if you want context menu to reflect caps
|
||||||
|
window.currentFolderCaps = caps;
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NEW: Breadcrumb Delegation Setup ---
|
// --- Breadcrumb Delegation Setup ---
|
||||||
// bindBreadcrumbEvents(); removed in favor of delegation
|
|
||||||
export function setupBreadcrumbDelegation() {
|
export function setupBreadcrumbDelegation() {
|
||||||
const container = document.getElementById("fileListTitle");
|
const container = document.getElementById("fileListTitle");
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -104,7 +130,6 @@ export function setupBreadcrumbDelegation() {
|
|||||||
|
|
||||||
// Click handler via delegation
|
// Click handler via delegation
|
||||||
function breadcrumbClickHandler(e) {
|
function breadcrumbClickHandler(e) {
|
||||||
// find the nearest .breadcrumb-link
|
|
||||||
const link = e.target.closest(".breadcrumb-link");
|
const link = e.target.closest(".breadcrumb-link");
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
|
|
||||||
@@ -115,12 +140,10 @@ function breadcrumbClickHandler(e) {
|
|||||||
window.currentFolder = folder;
|
window.currentFolder = folder;
|
||||||
localStorage.setItem("lastOpenedFolder", folder);
|
localStorage.setItem("lastOpenedFolder", folder);
|
||||||
|
|
||||||
// rebuild the title safely
|
|
||||||
updateBreadcrumbTitle(folder);
|
updateBreadcrumbTitle(folder);
|
||||||
|
applyFolderCapabilities(folder);
|
||||||
expandTreePath(folder);
|
expandTreePath(folder);
|
||||||
document.querySelectorAll(".folder-option").forEach(el =>
|
document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
|
||||||
el.classList.remove("selected")
|
|
||||||
);
|
|
||||||
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
||||||
if (target) target.classList.add("selected");
|
if (target) target.classList.add("selected");
|
||||||
|
|
||||||
@@ -158,20 +181,18 @@ function breadcrumbDropHandler(e) {
|
|||||||
}
|
}
|
||||||
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;
|
||||||
fetch("/api/file/moveFiles.php", {
|
|
||||||
|
fetchWithCsrf("/api/file/moveFiles.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
source: dragData.sourceFolder,
|
source: dragData.sourceFolder,
|
||||||
files: filesToMove,
|
files: filesToMove,
|
||||||
destination: dropFolder
|
destination: dropFolder
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(safeJson)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
||||||
@@ -186,47 +207,39 @@ function breadcrumbDropHandler(e) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ----------------------
|
/* ----------------------
|
||||||
Check Current User's Folder-Only Permission
|
Check Current User's Folder-Only Permission
|
||||||
----------------------*/
|
----------------------*/
|
||||||
// This function uses localStorage values (set during login) to determine if the current user is restricted.
|
// Authoritatively determine from the server; still write to localStorage for UI,
|
||||||
// If folderOnly is "true", then the personal folder (i.e. username) is forced as the effective root.
|
// but ignore any preexisting localStorage override for security.
|
||||||
function checkUserFolderPermission() {
|
async function checkUserFolderPermission() {
|
||||||
const username = localStorage.getItem("username");
|
const username = localStorage.getItem("username") || "";
|
||||||
console.log("checkUserFolderPermission: username =", username);
|
try {
|
||||||
if (!username) {
|
const res = await fetchWithCsrf("/api/getUserPermissions.php", {
|
||||||
console.warn("No username in localStorage; skipping getUserPermissions fetch.");
|
method: "GET",
|
||||||
return Promise.resolve(false);
|
credentials: "include"
|
||||||
}
|
|
||||||
if (localStorage.getItem("folderOnly") === "true") {
|
|
||||||
window.userFolderOnly = true;
|
|
||||||
console.log("checkUserFolderPermission: using localStorage.folderOnly = true");
|
|
||||||
localStorage.setItem("lastOpenedFolder", username);
|
|
||||||
window.currentFolder = username;
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
return fetch("/api/getUserPermissions.php", { credentials: "include" })
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(permissionsData => {
|
|
||||||
console.log("checkUserFolderPermission: permissionsData =", permissionsData);
|
|
||||||
if (permissionsData && permissionsData[username] && permissionsData[username].folderOnly) {
|
|
||||||
window.userFolderOnly = true;
|
|
||||||
localStorage.setItem("folderOnly", "true");
|
|
||||||
localStorage.setItem("lastOpenedFolder", username);
|
|
||||||
window.currentFolder = username;
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
window.userFolderOnly = false;
|
|
||||||
localStorage.setItem("folderOnly", "false");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error("Error fetching user permissions:", err);
|
|
||||||
window.userFolderOnly = false;
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
|
const permissionsData = await safeJson(res);
|
||||||
|
|
||||||
|
const isFolderOnly =
|
||||||
|
!!(permissionsData &&
|
||||||
|
permissionsData[username] &&
|
||||||
|
permissionsData[username].folderOnly);
|
||||||
|
|
||||||
|
window.userFolderOnly = isFolderOnly;
|
||||||
|
localStorage.setItem("folderOnly", isFolderOnly ? "true" : "false");
|
||||||
|
|
||||||
|
if (isFolderOnly && username) {
|
||||||
|
localStorage.setItem("lastOpenedFolder", username);
|
||||||
|
window.currentFolder = username;
|
||||||
|
}
|
||||||
|
return isFolderOnly;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching user permissions:", err);
|
||||||
|
window.userFolderOnly = false;
|
||||||
|
localStorage.setItem("folderOnly", "false");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------
|
/* ----------------------
|
||||||
@@ -236,7 +249,8 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
|||||||
const state = loadFolderTreeState();
|
const state = loadFolderTreeState();
|
||||||
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
|
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
|
||||||
for (const folder in tree) {
|
for (const folder in tree) {
|
||||||
if (folder.toLowerCase() === "trash") continue;
|
const name = folder.toLowerCase();
|
||||||
|
if (name === "trash" || name === "profile_pics") continue;
|
||||||
const fullPath = parentPath ? parentPath + "/" + folder : folder;
|
const fullPath = parentPath ? parentPath + "/" + folder : folder;
|
||||||
const hasChildren = Object.keys(tree[folder]).length > 0;
|
const hasChildren = Object.keys(tree[folder]).length > 0;
|
||||||
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
|
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
|
||||||
@@ -272,7 +286,7 @@ function expandTreePath(path) {
|
|||||||
const toggle = li.querySelector(".folder-toggle");
|
const toggle = li.querySelector(".folder-toggle");
|
||||||
if (toggle) {
|
if (toggle) {
|
||||||
toggle.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
toggle.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||||
let state = loadFolderTreeState();
|
const state = loadFolderTreeState();
|
||||||
state[cumulative] = "block";
|
state[cumulative] = "block";
|
||||||
saveFolderTreeState(state);
|
saveFolderTreeState(state);
|
||||||
}
|
}
|
||||||
@@ -306,20 +320,18 @@ function folderDropHandler(event) {
|
|||||||
}
|
}
|
||||||
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;
|
||||||
fetch("/api/file/moveFiles.php", {
|
|
||||||
|
fetchWithCsrf("/api/file/moveFiles.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
source: dragData.sourceFolder,
|
source: dragData.sourceFolder,
|
||||||
files: filesToMove,
|
files: filesToMove,
|
||||||
destination: dropFolder
|
destination: dropFolder
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(safeJson)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
||||||
@@ -337,7 +349,7 @@ function folderDropHandler(event) {
|
|||||||
/* ----------------------
|
/* ----------------------
|
||||||
Main Folder Tree Rendering and Event Binding
|
Main Folder Tree Rendering and Event Binding
|
||||||
----------------------*/
|
----------------------*/
|
||||||
// --- Helpers for safe breadcrumb rendering ---
|
// Safe breadcrumb DOM builder
|
||||||
function renderBreadcrumbFragment(folderPath) {
|
function renderBreadcrumbFragment(folderPath) {
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
const parts = folderPath.split("/");
|
const parts = folderPath.split("/");
|
||||||
@@ -360,51 +372,54 @@ function renderBreadcrumbFragment(folderPath) {
|
|||||||
return frag;
|
return frag;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBreadcrumbTitle(folder) {
|
export function updateBreadcrumbTitle(folder) {
|
||||||
const titleEl = document.getElementById("fileListTitle");
|
const titleEl = document.getElementById("fileListTitle");
|
||||||
|
if (!titleEl) return;
|
||||||
titleEl.textContent = "";
|
titleEl.textContent = "";
|
||||||
titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
|
titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
|
||||||
titleEl.appendChild(renderBreadcrumbFragment(folder));
|
titleEl.appendChild(renderBreadcrumbFragment(folder));
|
||||||
titleEl.appendChild(document.createTextNode(")"));
|
titleEl.appendChild(document.createTextNode(")"));
|
||||||
setupBreadcrumbDelegation();
|
setupBreadcrumbDelegation();
|
||||||
|
// Ensure context menu delegation is hooked to the dynamic breadcrumb container
|
||||||
|
bindFolderManagerContextMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFolderTree(selectedFolder) {
|
export async function loadFolderTree(selectedFolder) {
|
||||||
try {
|
try {
|
||||||
// Check if the user has folder-only permission.
|
// Check if the user has folder-only permission (server-authoritative).
|
||||||
await checkUserFolderPermission();
|
await checkUserFolderPermission();
|
||||||
|
|
||||||
// Determine effective root folder.
|
// Determine effective root folder.
|
||||||
const username = localStorage.getItem("username") || "root";
|
const username = localStorage.getItem("username") || "root";
|
||||||
let effectiveRoot = "root";
|
let effectiveRoot = "root";
|
||||||
let effectiveLabel = "(Root)";
|
let effectiveLabel = "(Root)";
|
||||||
if (window.userFolderOnly) {
|
if (window.userFolderOnly && username) {
|
||||||
effectiveRoot = username; // Use the username as the personal root.
|
effectiveRoot = username; // personal root
|
||||||
effectiveLabel = `(Root)`;
|
effectiveLabel = `(Root)`;
|
||||||
// Force override of any saved folder.
|
|
||||||
localStorage.setItem("lastOpenedFolder", username);
|
localStorage.setItem("lastOpenedFolder", username);
|
||||||
window.currentFolder = username;
|
window.currentFolder = username;
|
||||||
} else {
|
} else {
|
||||||
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
|
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build fetch URL.
|
// Fetch folder list from the server (server enforces scope).
|
||||||
let fetchUrl = '/api/folder/getFolderList.php';
|
const res = await fetchWithCsrf('/api/folder/getFolderList.php', {
|
||||||
if (window.userFolderOnly) {
|
method: 'GET',
|
||||||
fetchUrl += '?restricted=1';
|
credentials: 'include'
|
||||||
}
|
});
|
||||||
console.log("Fetching folder list from:", fetchUrl);
|
|
||||||
|
|
||||||
// Fetch folder list from the server.
|
if (res.status === 401) {
|
||||||
const response = await fetch(fetchUrl);
|
|
||||||
if (response.status === 401) {
|
|
||||||
console.error("Unauthorized: Please log in to view folders.");
|
|
||||||
showToast("Session expired. Please log in again.");
|
showToast("Session expired. Please log in again.");
|
||||||
window.location.href = "/api/auth/logout.php";
|
window.location.href = "/api/auth/logout.php";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let folderData = await response.json();
|
if (res.status === 403) {
|
||||||
console.log("Folder data received:", folderData);
|
showToast("You don't have permission to view folders.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderData = await safeJson(res);
|
||||||
|
|
||||||
let folders = [];
|
let folders = [];
|
||||||
if (Array.isArray(folderData) && folderData.length && typeof folderData[0] === "object" && folderData[0].folder) {
|
if (Array.isArray(folderData) && folderData.length && typeof folderData[0] === "object" && folderData[0].folder) {
|
||||||
folders = folderData.map(item => item.folder);
|
folders = folderData.map(item => item.folder);
|
||||||
@@ -412,13 +427,12 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
folders = folderData;
|
folders = folderData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove any global "root" entry.
|
// Remove any global "root" entry (server shouldn't return it, but be safe).
|
||||||
folders = folders.filter(folder => folder.toLowerCase() !== "root");
|
folders = folders.filter(folder => folder.toLowerCase() !== "root");
|
||||||
|
|
||||||
// If restricted, filter folders: keep only those that start with effectiveRoot + "/" (do not include effectiveRoot itself).
|
// If restricted, filter client-side view to subtree for UX (server still enforces).
|
||||||
if (window.userFolderOnly && effectiveRoot !== "root") {
|
if (window.userFolderOnly && effectiveRoot !== "root") {
|
||||||
folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/"));
|
folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/"));
|
||||||
// Force current folder to be the effective root.
|
|
||||||
localStorage.setItem("lastOpenedFolder", effectiveRoot);
|
localStorage.setItem("lastOpenedFolder", effectiveRoot);
|
||||||
window.currentFolder = effectiveRoot;
|
window.currentFolder = effectiveRoot;
|
||||||
}
|
}
|
||||||
@@ -454,8 +468,9 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
}
|
}
|
||||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||||
|
|
||||||
// Initial breadcrumb update
|
// Initial breadcrumb + file list
|
||||||
updateBreadcrumbTitle(window.currentFolder);
|
updateBreadcrumbTitle(window.currentFolder);
|
||||||
|
applyFolderCapabilities(window.currentFolder);
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
|
||||||
const folderState = loadFolderTreeState();
|
const folderState = loadFolderTreeState();
|
||||||
@@ -479,8 +494,8 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
window.currentFolder = selected;
|
window.currentFolder = selected;
|
||||||
localStorage.setItem("lastOpenedFolder", selected);
|
localStorage.setItem("lastOpenedFolder", selected);
|
||||||
|
|
||||||
// Safe breadcrumb update
|
|
||||||
updateBreadcrumbTitle(selected);
|
updateBreadcrumbTitle(selected);
|
||||||
|
applyFolderCapabilities(selected);
|
||||||
loadFileList(selected);
|
loadFileList(selected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -492,7 +507,7 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const nestedUl = container.querySelector("#rootRow + ul");
|
const nestedUl = container.querySelector("#rootRow + ul");
|
||||||
if (nestedUl) {
|
if (nestedUl) {
|
||||||
let state = loadFolderTreeState();
|
const state = loadFolderTreeState();
|
||||||
if (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded")) {
|
if (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded")) {
|
||||||
nestedUl.classList.remove("collapsed");
|
nestedUl.classList.remove("collapsed");
|
||||||
nestedUl.classList.add("expanded");
|
nestedUl.classList.add("expanded");
|
||||||
@@ -515,7 +530,7 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const siblingUl = this.parentNode.querySelector("ul");
|
const siblingUl = this.parentNode.querySelector("ul");
|
||||||
const folderPath = this.getAttribute("data-folder");
|
const folderPath = this.getAttribute("data-folder");
|
||||||
let state = loadFolderTreeState();
|
const state = loadFolderTreeState();
|
||||||
if (siblingUl) {
|
if (siblingUl) {
|
||||||
if (siblingUl.classList.contains("collapsed") || !siblingUl.classList.contains("expanded")) {
|
if (siblingUl.classList.contains("collapsed") || !siblingUl.classList.contains("expanded")) {
|
||||||
siblingUl.classList.remove("collapsed");
|
siblingUl.classList.remove("collapsed");
|
||||||
@@ -535,10 +550,12 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading folder tree:", error);
|
console.error("Error loading folder tree:", error);
|
||||||
|
if (error.status === 403) {
|
||||||
|
showToast("You don't have permission to view folders.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// For backward compatibility.
|
// For backward compatibility.
|
||||||
export function loadFolderList(selectedFolder) {
|
export function loadFolderList(selectedFolder) {
|
||||||
loadFolderTree(selectedFolder);
|
loadFolderTree(selectedFolder);
|
||||||
@@ -547,177 +564,203 @@ export function loadFolderList(selectedFolder) {
|
|||||||
/* ----------------------
|
/* ----------------------
|
||||||
Folder Management (Rename, Delete, Create)
|
Folder Management (Rename, Delete, Create)
|
||||||
----------------------*/
|
----------------------*/
|
||||||
document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal);
|
const renameBtn = document.getElementById("renameFolderBtn");
|
||||||
document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal);
|
if (renameBtn) renameBtn.addEventListener("click", openRenameFolderModal);
|
||||||
|
|
||||||
function openRenameFolderModal() {
|
const deleteBtn = document.getElementById("deleteFolderBtn");
|
||||||
|
if (deleteBtn) deleteBtn.addEventListener("click", openDeleteFolderModal);
|
||||||
|
|
||||||
|
export function openRenameFolderModal() {
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
if (!selectedFolder || selectedFolder === "root") {
|
if (!selectedFolder || selectedFolder === "root") {
|
||||||
showToast("Please select a valid folder to rename.");
|
showToast("Please select a valid folder to rename.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parts = selectedFolder.split("/");
|
const parts = selectedFolder.split("/");
|
||||||
document.getElementById("newRenameFolderName").value = parts[parts.length - 1];
|
const input = document.getElementById("newRenameFolderName");
|
||||||
document.getElementById("renameFolderModal").style.display = "block";
|
const modal = document.getElementById("renameFolderModal");
|
||||||
|
if (!input || !modal) return;
|
||||||
|
input.value = parts[parts.length - 1];
|
||||||
|
modal.style.display = "block";
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const input = document.getElementById("newRenameFolderName");
|
|
||||||
input.focus();
|
input.focus();
|
||||||
input.select();
|
input.select();
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("cancelRenameFolder").addEventListener("click", function () {
|
const cancelRename = document.getElementById("cancelRenameFolder");
|
||||||
document.getElementById("renameFolderModal").style.display = "none";
|
if (cancelRename) {
|
||||||
document.getElementById("newRenameFolderName").value = "";
|
cancelRename.addEventListener("click", function () {
|
||||||
});
|
const modal = document.getElementById("renameFolderModal");
|
||||||
|
const input = document.getElementById("newRenameFolderName");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
if (input) input.value = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
attachEnterKeyListener("renameFolderModal", "submitRenameFolder");
|
attachEnterKeyListener("renameFolderModal", "submitRenameFolder");
|
||||||
document.getElementById("submitRenameFolder").addEventListener("click", function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const selectedFolder = window.currentFolder || "root";
|
|
||||||
const newNameBasename = document.getElementById("newRenameFolderName").value.trim();
|
|
||||||
if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) {
|
|
||||||
showToast("Please enter a valid new folder name.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parentPath = getParentFolder(selectedFolder);
|
|
||||||
const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename;
|
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
|
||||||
if (!csrfToken) {
|
|
||||||
showToast("CSRF token not loaded yet! Please try again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch("/api/folder/renameFolder.php", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ oldFolder: window.currentFolder, newFolder: newFolderFull })
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
showToast("Folder renamed successfully!");
|
|
||||||
window.currentFolder = newFolderFull;
|
|
||||||
localStorage.setItem("lastOpenedFolder", newFolderFull);
|
|
||||||
loadFolderList(newFolderFull);
|
|
||||||
} else {
|
|
||||||
showToast("Error: " + (data.error || "Could not rename folder"));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => console.error("Error renaming folder:", error))
|
|
||||||
.finally(() => {
|
|
||||||
document.getElementById("renameFolderModal").style.display = "none";
|
|
||||||
document.getElementById("newRenameFolderName").value = "";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function openDeleteFolderModal() {
|
const submitRename = document.getElementById("submitRenameFolder");
|
||||||
|
if (submitRename) {
|
||||||
|
submitRename.addEventListener("click", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const selectedFolder = window.currentFolder || "root";
|
||||||
|
const input = document.getElementById("newRenameFolderName");
|
||||||
|
if (!input) return;
|
||||||
|
const newNameBasename = input.value.trim();
|
||||||
|
if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) {
|
||||||
|
showToast("Please enter a valid new folder name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parentPath = getParentFolder(selectedFolder);
|
||||||
|
const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename;
|
||||||
|
|
||||||
|
fetchWithCsrf("/api/folder/renameFolder.php", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ oldFolder: window.currentFolder, newFolder: newFolderFull })
|
||||||
|
})
|
||||||
|
.then(safeJson)
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
showToast("Folder renamed successfully!");
|
||||||
|
window.currentFolder = newFolderFull;
|
||||||
|
localStorage.setItem("lastOpenedFolder", newFolderFull);
|
||||||
|
loadFolderList(newFolderFull);
|
||||||
|
} else {
|
||||||
|
showToast("Error: " + (data.error || "Could not rename folder"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error("Error renaming folder:", error))
|
||||||
|
.finally(() => {
|
||||||
|
const modal = document.getElementById("renameFolderModal");
|
||||||
|
const input2 = document.getElementById("newRenameFolderName");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
if (input2) input2.value = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openDeleteFolderModal() {
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
if (!selectedFolder || selectedFolder === "root") {
|
if (!selectedFolder || selectedFolder === "root") {
|
||||||
showToast("Please select a valid folder to delete.");
|
showToast("Please select a valid folder to delete.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.getElementById("deleteFolderMessage").textContent =
|
const msgEl = document.getElementById("deleteFolderMessage");
|
||||||
"Are you sure you want to delete folder " + selectedFolder + "?";
|
const modal = document.getElementById("deleteFolderModal");
|
||||||
document.getElementById("deleteFolderModal").style.display = "block";
|
if (!msgEl || !modal) return;
|
||||||
|
msgEl.textContent = "Are you sure you want to delete folder " + selectedFolder + "?";
|
||||||
|
modal.style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("cancelDeleteFolder").addEventListener("click", function () {
|
const cancelDelete = document.getElementById("cancelDeleteFolder");
|
||||||
document.getElementById("deleteFolderModal").style.display = "none";
|
if (cancelDelete) {
|
||||||
});
|
cancelDelete.addEventListener("click", function () {
|
||||||
|
const modal = document.getElementById("deleteFolderModal");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
attachEnterKeyListener("deleteFolderModal", "confirmDeleteFolder");
|
attachEnterKeyListener("deleteFolderModal", "confirmDeleteFolder");
|
||||||
document.getElementById("confirmDeleteFolder").addEventListener("click", function () {
|
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const confirmDelete = document.getElementById("confirmDeleteFolder");
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
if (confirmDelete) {
|
||||||
fetch("/api/folder/deleteFolder.php", {
|
confirmDelete.addEventListener("click", function () {
|
||||||
method: "POST",
|
const selectedFolder = window.currentFolder || "root";
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
fetchWithCsrf("/api/folder/deleteFolder.php", {
|
||||||
"X-CSRF-Token": csrfToken
|
method: "POST",
|
||||||
},
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ folder: selectedFolder })
|
credentials: "include",
|
||||||
})
|
body: JSON.stringify({ folder: selectedFolder })
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
showToast("Folder deleted successfully!");
|
|
||||||
window.currentFolder = getParentFolder(selectedFolder);
|
|
||||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
|
||||||
loadFolderList(window.currentFolder);
|
|
||||||
} else {
|
|
||||||
showToast("Error: " + (data.error || "Could not delete folder"));
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(error => console.error("Error deleting folder:", error))
|
.then(safeJson)
|
||||||
.finally(() => {
|
.then(data => {
|
||||||
document.getElementById("deleteFolderModal").style.display = "none";
|
if (data.success) {
|
||||||
});
|
showToast("Folder deleted successfully!");
|
||||||
});
|
window.currentFolder = getParentFolder(selectedFolder);
|
||||||
|
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||||
document.getElementById("createFolderBtn").addEventListener("click", function () {
|
loadFolderList(window.currentFolder);
|
||||||
document.getElementById("createFolderModal").style.display = "block";
|
} else {
|
||||||
document.getElementById("newFolderName").focus();
|
showToast("Error: " + (data.error || "Could not delete folder"));
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("cancelCreateFolder").addEventListener("click", function () {
|
|
||||||
document.getElementById("createFolderModal").style.display = "none";
|
|
||||||
document.getElementById("newFolderName").value = "";
|
|
||||||
});
|
|
||||||
attachEnterKeyListener("createFolderModal", "submitCreateFolder");
|
|
||||||
document.getElementById("submitCreateFolder").addEventListener("click", async () => {
|
|
||||||
const folderInput = document.getElementById("newFolderName").value.trim();
|
|
||||||
if (!folderInput) return showToast("Please enter a folder name.");
|
|
||||||
|
|
||||||
const selectedFolder = window.currentFolder || "root";
|
|
||||||
const parent = selectedFolder === "root" ? "" : selectedFolder;
|
|
||||||
|
|
||||||
// 1) Guarantee fresh CSRF
|
|
||||||
try {
|
|
||||||
await loadCsrfToken();
|
|
||||||
} catch {
|
|
||||||
return showToast("Could not refresh CSRF token. Please reload.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Call with fetchWithCsrf
|
|
||||||
fetchWithCsrf("/api/folder/createFolder.php", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ folderName: folderInput, parent })
|
|
||||||
})
|
|
||||||
.then(async res => {
|
|
||||||
if (!res.ok) {
|
|
||||||
// pull out a JSON error, or fallback to status text
|
|
||||||
let err;
|
|
||||||
try {
|
|
||||||
const j = await res.json();
|
|
||||||
err = j.error || j.message || res.statusText;
|
|
||||||
} catch {
|
|
||||||
err = res.statusText;
|
|
||||||
}
|
}
|
||||||
throw new Error(err);
|
})
|
||||||
}
|
.catch(error => console.error("Error deleting folder:", error))
|
||||||
return res.json();
|
.finally(() => {
|
||||||
|
const modal = document.getElementById("deleteFolderModal");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBtn = document.getElementById("createFolderBtn");
|
||||||
|
if (createBtn) {
|
||||||
|
createBtn.addEventListener("click", function () {
|
||||||
|
const modal = document.getElementById("createFolderModal");
|
||||||
|
const input = document.getElementById("newFolderName");
|
||||||
|
if (modal) modal.style.display = "block";
|
||||||
|
if (input) input.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelCreate = document.getElementById("cancelCreateFolder");
|
||||||
|
if (cancelCreate) {
|
||||||
|
cancelCreate.addEventListener("click", function () {
|
||||||
|
const modal = document.getElementById("createFolderModal");
|
||||||
|
const input = document.getElementById("newFolderName");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
if (input) input.value = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
attachEnterKeyListener("createFolderModal", "submitCreateFolder");
|
||||||
|
|
||||||
|
const submitCreate = document.getElementById("submitCreateFolder");
|
||||||
|
if (submitCreate) {
|
||||||
|
submitCreate.addEventListener("click", async () => {
|
||||||
|
const input = document.getElementById("newFolderName");
|
||||||
|
const folderInput = input ? input.value.trim() : "";
|
||||||
|
if (!folderInput) return showToast("Please enter a folder name.");
|
||||||
|
|
||||||
|
const selectedFolder = window.currentFolder || "root";
|
||||||
|
const parent = selectedFolder === "root" ? "" : selectedFolder;
|
||||||
|
|
||||||
|
// 1) Guarantee fresh CSRF
|
||||||
|
try {
|
||||||
|
await loadCsrfToken();
|
||||||
|
} catch {
|
||||||
|
return showToast("Could not refresh CSRF token. Please reload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Call with fetchWithCsrf
|
||||||
|
fetchWithCsrf("/api/folder/createFolder.php", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ folderName: folderInput, parent })
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(safeJson)
|
||||||
showToast("Folder created!");
|
.then(data => {
|
||||||
const full = parent ? `${parent}/${folderInput}` : folderInput;
|
if (!data.success) throw new Error(data.error || "Server rejected the request");
|
||||||
window.currentFolder = full;
|
showToast("Folder created!");
|
||||||
localStorage.setItem("lastOpenedFolder", full);
|
const full = parent ? `${parent}/${folderInput}` : folderInput;
|
||||||
loadFolderList(full);
|
window.currentFolder = full;
|
||||||
})
|
localStorage.setItem("lastOpenedFolder", full);
|
||||||
.catch(e => {
|
loadFolderList(full);
|
||||||
showToast("Error creating folder: " + e.message);
|
})
|
||||||
})
|
.catch(e => {
|
||||||
.finally(() => {
|
showToast("Error creating folder: " + e.message);
|
||||||
document.getElementById("createFolderModal").style.display = "none";
|
})
|
||||||
document.getElementById("newFolderName").value = "";
|
.finally(() => {
|
||||||
});
|
const modal = document.getElementById("createFolderModal");
|
||||||
});
|
const input2 = document.getElementById("newFolderName");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
if (input2) input2.value = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
|
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
|
||||||
function showFolderManagerContextMenu(x, y, menuItems) {
|
export function showFolderManagerContextMenu(x, y, menuItems) {
|
||||||
let menu = document.getElementById("folderManagerContextMenu");
|
let menu = document.getElementById("folderManagerContextMenu");
|
||||||
if (!menu) {
|
if (!menu) {
|
||||||
menu = document.createElement("div");
|
menu = document.createElement("div");
|
||||||
@@ -764,7 +807,7 @@ function showFolderManagerContextMenu(x, y, menuItems) {
|
|||||||
menu.style.display = "block";
|
menu.style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideFolderManagerContextMenu() {
|
export function hideFolderManagerContextMenu() {
|
||||||
const menu = document.getElementById("folderManagerContextMenu");
|
const menu = document.getElementById("folderManagerContextMenu");
|
||||||
if (menu) {
|
if (menu) {
|
||||||
menu.style.display = "none";
|
menu.style.display = "none";
|
||||||
@@ -772,21 +815,28 @@ function hideFolderManagerContextMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function folderManagerContextMenuHandler(e) {
|
function folderManagerContextMenuHandler(e) {
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const target = e.target.closest(".folder-option, .breadcrumb-link");
|
const target = e.target.closest(".folder-option, .breadcrumb-link");
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
const folder = target.getAttribute("data-folder");
|
const folder = target.getAttribute("data-folder");
|
||||||
if (!folder) return;
|
if (!folder) return;
|
||||||
window.currentFolder = folder;
|
window.currentFolder = folder;
|
||||||
|
|
||||||
|
// Visual selection
|
||||||
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
|
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
|
||||||
target.classList.add("selected");
|
target.classList.add("selected");
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
label: t("create_folder"),
|
label: t("create_folder"),
|
||||||
action: () => {
|
action: () => {
|
||||||
document.getElementById("createFolderModal").style.display = "block";
|
const modal = document.getElementById("createFolderModal");
|
||||||
document.getElementById("newFolderName").focus();
|
const input = document.getElementById("newFolderName");
|
||||||
|
if (modal) modal.style.display = "block";
|
||||||
|
if (input) input.focus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -795,7 +845,7 @@ function folderManagerContextMenuHandler(e) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("folder_share"),
|
label: t("folder_share"),
|
||||||
action: () => { openFolderShareModal(); }
|
action: () => { openFolderShareModal(folder); }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("delete_folder"),
|
label: t("delete_folder"),
|
||||||
@@ -805,17 +855,34 @@ function folderManagerContextMenuHandler(e) {
|
|||||||
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delegate contextmenu so it works with dynamically re-rendered breadcrumbs
|
||||||
function bindFolderManagerContextMenu() {
|
function bindFolderManagerContextMenu() {
|
||||||
const container = document.getElementById("folderTreeContainer");
|
const tree = document.getElementById("folderTreeContainer");
|
||||||
if (container) {
|
if (tree) {
|
||||||
container.removeEventListener("contextmenu", folderManagerContextMenuHandler);
|
// remove old bound handler if present
|
||||||
container.addEventListener("contextmenu", folderManagerContextMenuHandler, false);
|
if (tree._ctxHandler) {
|
||||||
|
tree.removeEventListener("contextmenu", tree._ctxHandler, false);
|
||||||
|
}
|
||||||
|
tree._ctxHandler = function (e) {
|
||||||
|
const onOption = e.target.closest(".folder-option");
|
||||||
|
if (!onOption) return;
|
||||||
|
folderManagerContextMenuHandler(e);
|
||||||
|
};
|
||||||
|
tree.addEventListener("contextmenu", tree._ctxHandler, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = document.getElementById("fileListTitle");
|
||||||
|
if (title) {
|
||||||
|
if (title._ctxHandler) {
|
||||||
|
title.removeEventListener("contextmenu", title._ctxHandler, false);
|
||||||
|
}
|
||||||
|
title._ctxHandler = function (e) {
|
||||||
|
const onCrumb = e.target.closest(".breadcrumb-link");
|
||||||
|
if (!onCrumb) return;
|
||||||
|
folderManagerContextMenuHandler(e);
|
||||||
|
};
|
||||||
|
title.addEventListener("contextmenu", title._ctxHandler, false);
|
||||||
}
|
}
|
||||||
const breadcrumbNodes = document.querySelectorAll(".breadcrumb-link");
|
|
||||||
breadcrumbNodes.forEach(node => {
|
|
||||||
node.removeEventListener("contextmenu", folderManagerContextMenuHandler);
|
|
||||||
node.addEventListener("contextmenu", folderManagerContextMenuHandler, false);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("click", function () {
|
document.addEventListener("click", function () {
|
||||||
@@ -824,8 +891,8 @@ document.addEventListener("click", function () {
|
|||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
document.addEventListener("keydown", function (e) {
|
document.addEventListener("keydown", function (e) {
|
||||||
const tag = e.target.tagName.toLowerCase();
|
const tag = e.target.tagName ? e.target.tagName.toLowerCase() : "";
|
||||||
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) {
|
if (tag === "input" || tag === "textarea" || (e.target && e.target.isContentEditable)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Delete" || e.key === "Backspace" || e.keyCode === 46 || e.keyCode === 8) {
|
if (e.key === "Delete" || e.key === "Backspace" || e.keyCode === 46 || e.keyCode === 8) {
|
||||||
@@ -846,7 +913,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
showToast("Please select a valid folder to share.");
|
showToast("Please select a valid folder to share.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Call the folder share modal from the module.
|
|
||||||
openFolderShareModal(selectedFolder);
|
openFolderShareModal(selectedFolder);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -854,4 +920,5 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initial context menu delegation bind
|
||||||
bindFolderManagerContextMenu();
|
bindFolderManagerContextMenu();
|
||||||
@@ -202,6 +202,11 @@ const translations = {
|
|||||||
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
||||||
"admin_panel": "Admin Panel",
|
"admin_panel": "Admin Panel",
|
||||||
"user_panel": "User Panel",
|
"user_panel": "User Panel",
|
||||||
|
"user_settings": "User Settings",
|
||||||
|
"save_profile_picture": "Save Profile Picture",
|
||||||
|
"please_select_picture": "Please select a picture",
|
||||||
|
"profile_picture_updated": "Profile picture updated",
|
||||||
|
"error_updating_picture": "Error updating profile picture",
|
||||||
"trash_restore_delete": "Trash Restore/Delete",
|
"trash_restore_delete": "Trash Restore/Delete",
|
||||||
"totp_settings": "TOTP Settings",
|
"totp_settings": "TOTP Settings",
|
||||||
"enable_totp": "Enable TOTP",
|
"enable_totp": "Enable TOTP",
|
||||||
@@ -260,7 +265,17 @@ const translations = {
|
|||||||
"show": "Show",
|
"show": "Show",
|
||||||
"items_per_page": "items per page",
|
"items_per_page": "items per page",
|
||||||
"columns": "Columns",
|
"columns": "Columns",
|
||||||
"api_docs": "API Docs"
|
"row_height": "Row Height",
|
||||||
|
"api_docs": "API Docs",
|
||||||
|
"show_folders_above_files": "Show folders above files",
|
||||||
|
"display": "Display",
|
||||||
|
"create_file": "Create File",
|
||||||
|
"create_new_file": "Create New File",
|
||||||
|
"enter_file_name": "Enter file name",
|
||||||
|
"newfile_placeholder": "New file name",
|
||||||
|
"file_created_successfully": "File created successfully!",
|
||||||
|
"error_creating_file": "Error creating file",
|
||||||
|
"file_created": "File created successfully!"
|
||||||
},
|
},
|
||||||
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.",
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { sendRequest } from './networkUtils.js';
|
|||||||
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
|
import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js';
|
||||||
import { initUpload } from './upload.js';
|
import { initUpload } from './upload.js';
|
||||||
import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js';
|
import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js';
|
||||||
const _originalFetch = window.fetch;
|
|
||||||
window.fetch = fetchWithCsrf;
|
|
||||||
import { loadFolderTree } from './folderManager.js';
|
import { loadFolderTree } from './folderManager.js';
|
||||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
|
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
|
||||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
|
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js';
|
||||||
@@ -14,10 +12,127 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload }
|
|||||||
import { editFile, saveFile } from './fileEditor.js';
|
import { editFile, saveFile } from './fileEditor.js';
|
||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
CSRF HOTFIX UTILITIES
|
||||||
|
========================= */
|
||||||
|
const _nativeFetch = window.fetch; // keep the real fetch
|
||||||
|
|
||||||
|
function setCsrfToken(token) {
|
||||||
|
if (!token) return;
|
||||||
|
window.csrfToken = token;
|
||||||
|
localStorage.setItem('csrf', token);
|
||||||
|
|
||||||
|
// meta tag for easy access in other places
|
||||||
|
let meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta');
|
||||||
|
meta.name = 'csrf-token';
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
}
|
||||||
|
meta.content = token;
|
||||||
|
}
|
||||||
|
function getCsrfToken() {
|
||||||
|
return window.csrfToken || localStorage.getItem('csrf') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed CSRF from storage ASAP (before any requests)
|
||||||
|
setCsrfToken(getCsrfToken());
|
||||||
|
|
||||||
|
// Wrap the existing fetchWithCsrf so we also capture rotated tokens from headers.
|
||||||
|
async function fetchWithCsrfAndRefresh(input, init = {}) {
|
||||||
|
const res = await fetchWithCsrf(input, init);
|
||||||
|
try {
|
||||||
|
const rotated = res.headers?.get('X-CSRF-Token');
|
||||||
|
if (rotated) setCsrfToken(rotated);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace global fetch with the wrapped version so *all* callers benefit.
|
||||||
|
window.fetch = fetchWithCsrfAndRefresh;
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
SAFE API HELPERS
|
||||||
|
========================= */
|
||||||
|
export async function apiGETJSON(url, opts = {}) {
|
||||||
|
const res = await fetch(url, { credentials: "include", ...opts });
|
||||||
|
if (res.status === 401) throw new Error("auth");
|
||||||
|
if (res.status === 403) throw new Error("forbidden");
|
||||||
|
if (!res.ok) throw new Error(`http ${res.status}`);
|
||||||
|
try { return await res.json(); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPOSTJSON(url, body, opts = {}) {
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": getCsrfToken(),
|
||||||
|
...(opts.headers || {})
|
||||||
|
};
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
...opts
|
||||||
|
});
|
||||||
|
if (res.status === 401) throw new Error("auth");
|
||||||
|
if (res.status === 403) throw new Error("forbidden");
|
||||||
|
if (!res.ok) throw new Error(`http ${res.status}`);
|
||||||
|
try { return await res.json(); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: expose on window for legacy callers
|
||||||
|
window.apiGETJSON = apiGETJSON;
|
||||||
|
window.apiPOSTJSON = apiPOSTJSON;
|
||||||
|
|
||||||
|
// Global handler to keep UX friendly if something forgets to catch
|
||||||
|
window.addEventListener("unhandledrejection", (ev) => {
|
||||||
|
const msg = (ev?.reason && ev.reason.message) || "";
|
||||||
|
if (msg === "auth") {
|
||||||
|
showToast(t("please_sign_in_again") || "Please sign in again.", "error");
|
||||||
|
ev.preventDefault();
|
||||||
|
} else if (msg === "forbidden") {
|
||||||
|
showToast(t("no_access_to_resource") || "You don’t have access to that.", "error");
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
APP INIT
|
||||||
|
========================= */
|
||||||
|
|
||||||
export function initializeApp() {
|
export function initializeApp() {
|
||||||
|
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
||||||
|
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
||||||
|
|
||||||
window.currentFolder = "root";
|
window.currentFolder = "root";
|
||||||
|
const stored = localStorage.getItem('showFoldersInList');
|
||||||
|
window.showFoldersInList = stored === null ? true : stored === 'true';
|
||||||
|
|
||||||
initTagSearch();
|
initTagSearch();
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
|
||||||
|
const fileListArea = document.getElementById('fileListContainer');
|
||||||
|
const uploadArea = document.getElementById('uploadDropArea');
|
||||||
|
if (fileListArea && uploadArea) {
|
||||||
|
fileListArea.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
fileListArea.classList.add('drop-hover');
|
||||||
|
});
|
||||||
|
fileListArea.addEventListener('dragleave', () => {
|
||||||
|
fileListArea.classList.remove('drop-hover');
|
||||||
|
});
|
||||||
|
fileListArea.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
fileListArea.classList.remove('drop-hover');
|
||||||
|
uploadArea.dispatchEvent(new DragEvent('drop', {
|
||||||
|
dataTransfer: e.dataTransfer,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
initDragAndDrop();
|
initDragAndDrop();
|
||||||
loadSidebarOrder();
|
loadSidebarOrder();
|
||||||
loadHeaderOrder();
|
loadHeaderOrder();
|
||||||
@@ -25,48 +140,48 @@ export function initializeApp() {
|
|||||||
initUpload();
|
initUpload();
|
||||||
loadFolderTree();
|
loadFolderTree();
|
||||||
setupTrashRestoreDelete();
|
setupTrashRestoreDelete();
|
||||||
loadAdminConfigFunc();
|
// NOTE: loadAdminConfigFunc() is called once in DOMContentLoaded; calling here would duplicate requests.
|
||||||
|
|
||||||
const helpBtn = document.getElementById("folderHelpBtn");
|
const helpBtn = document.getElementById("folderHelpBtn");
|
||||||
const helpTooltip = document.getElementById("folderHelpTooltip");
|
const helpTooltip = document.getElementById("folderHelpTooltip");
|
||||||
if (helpBtn && helpTooltip) {
|
if (helpBtn && helpTooltip) {
|
||||||
helpBtn.addEventListener("click", () => {
|
helpBtn.addEventListener("click", () => {
|
||||||
helpTooltip.style.display =
|
helpTooltip.style.display =
|
||||||
helpTooltip.style.display === "block" ? "none" : "block";
|
helpTooltip.style.display === "block" ? "none" : "block";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap/refresh CSRF from the server.
|
||||||
|
* Uses the *native* fetch to avoid any wrapper loops and to work even if we don't
|
||||||
|
* yet have a token. Also accepts a rotated token from the response header.
|
||||||
|
*/
|
||||||
export function loadCsrfToken() {
|
export function loadCsrfToken() {
|
||||||
return fetchWithCsrf('/api/auth/token.php', {
|
return _nativeFetch('/api/auth/token.php', { method: 'GET', credentials: 'include' })
|
||||||
method: 'GET'
|
.then(async res => {
|
||||||
})
|
// header-based rotation
|
||||||
.then(res => {
|
const hdr = res.headers.get('X-CSRF-Token');
|
||||||
if (!res.ok) {
|
if (hdr) setCsrfToken(hdr);
|
||||||
throw new Error(`Token fetch failed with status ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then(({ csrf_token, share_url }) => {
|
|
||||||
// Update global and <meta>
|
|
||||||
window.csrfToken = csrf_token;
|
|
||||||
let meta = document.querySelector('meta[name="csrf-token"]');
|
|
||||||
if (!meta) {
|
|
||||||
meta = document.createElement('meta');
|
|
||||||
meta.name = 'csrf-token';
|
|
||||||
document.head.appendChild(meta);
|
|
||||||
}
|
|
||||||
meta.content = csrf_token;
|
|
||||||
|
|
||||||
|
// body (if provided)
|
||||||
|
let body = {};
|
||||||
|
try { body = await res.json(); } catch { /* token endpoint may return empty */ }
|
||||||
|
|
||||||
|
const token = body.csrf_token || getCsrfToken();
|
||||||
|
setCsrfToken(token);
|
||||||
|
|
||||||
|
// share-url meta should reflect the actual origin
|
||||||
|
const actualShare = window.location.origin;
|
||||||
let shareMeta = document.querySelector('meta[name="share-url"]');
|
let shareMeta = document.querySelector('meta[name="share-url"]');
|
||||||
if (!shareMeta) {
|
if (!shareMeta) {
|
||||||
shareMeta = document.createElement('meta');
|
shareMeta = document.createElement('meta');
|
||||||
shareMeta.name = 'share-url';
|
shareMeta.name = 'share-url';
|
||||||
document.head.appendChild(shareMeta);
|
document.head.appendChild(shareMeta);
|
||||||
}
|
}
|
||||||
shareMeta.content = share_url;
|
shareMeta.content = actualShare;
|
||||||
|
|
||||||
return { csrf_token, share_url };
|
return { csrf_token: token, share_url: actualShare };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,21 +192,16 @@ if (params.get('logout') === '1') {
|
|||||||
localStorage.removeItem("userTOTPEnabled");
|
localStorage.removeItem("userTOTPEnabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Wire up logoutBtn right away
|
export function triggerLogout() {
|
||||||
const logoutBtn = document.getElementById("logoutBtn");
|
_nativeFetch("/api/auth/logout.php", {
|
||||||
if (logoutBtn) {
|
method: "POST",
|
||||||
logoutBtn.addEventListener("click", () => {
|
credentials: "include",
|
||||||
fetch("/api/auth/logout.php", {
|
headers: { "X-CSRF-Token": getCsrfToken() }
|
||||||
method: "POST",
|
})
|
||||||
credentials: "include",
|
.then(() => window.location.reload(true))
|
||||||
headers: { "X-CSRF-Token": window.csrfToken }
|
.catch(() => { });
|
||||||
})
|
|
||||||
.then(() => window.location.reload(true))
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Expose functions for inline handlers.
|
// Expose functions for inline handlers.
|
||||||
window.sendRequest = sendRequest;
|
window.sendRequest = sendRequest;
|
||||||
window.toggleVisibility = toggleVisibility;
|
window.toggleVisibility = toggleVisibility;
|
||||||
@@ -106,105 +216,80 @@ window.openDownloadModal = openDownloadModal;
|
|||||||
window.currentFolder = "root";
|
window.currentFolder = "root";
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Load admin config once here; non-admins may get 403, which is fine.
|
||||||
|
loadAdminConfigFunc();
|
||||||
|
|
||||||
loadAdminConfigFunc(); // Then fetch the latest config and update.
|
// i18n
|
||||||
// Retrieve the saved language from localStorage; default to "en"
|
|
||||||
const savedLanguage = localStorage.getItem("language") || "en";
|
const savedLanguage = localStorage.getItem("language") || "en";
|
||||||
// Set the locale based on the saved language
|
|
||||||
setLocale(savedLanguage);
|
setLocale(savedLanguage);
|
||||||
// Apply the translations to update the UI
|
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
// First, load the CSRF token (with retry).
|
|
||||||
loadCsrfToken().then(() => {
|
|
||||||
// Once CSRF token is loaded, initialize authentication.
|
|
||||||
initAuth();
|
|
||||||
|
|
||||||
// Continue with initializations that rely on a valid CSRF token:
|
// 1) Get/refresh CSRF first
|
||||||
checkAuthentication().then(authenticated => {
|
loadCsrfToken()
|
||||||
if (authenticated) {
|
.then(() => {
|
||||||
document.getElementById('loadingOverlay').remove();
|
// 2) Auth boot
|
||||||
initializeApp();
|
initAuth();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Other DOM initialization that can happen after CSRF is ready.
|
// 3) If authenticated, start app
|
||||||
const newPasswordInput = document.getElementById("newPassword");
|
checkAuthentication().then(authenticated => {
|
||||||
if (newPasswordInput) {
|
if (authenticated) {
|
||||||
newPasswordInput.addEventListener("input", function () {
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
console.log("newPassword input event:", this.value);
|
if (overlay) overlay.remove();
|
||||||
|
initializeApp();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
console.error("newPassword input not found!");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Dark Mode Persistence ---
|
// --- Dark Mode Persistence ---
|
||||||
const darkModeToggle = document.getElementById("darkModeToggle");
|
const darkModeToggle = document.getElementById("darkModeToggle");
|
||||||
const darkModeIcon = document.getElementById("darkModeIcon");
|
const darkModeIcon = document.getElementById("darkModeIcon");
|
||||||
|
|
||||||
if (darkModeToggle && darkModeIcon) {
|
if (darkModeToggle && darkModeIcon) {
|
||||||
// 1) Load stored preference (or null)
|
let stored = localStorage.getItem("darkMode");
|
||||||
let stored = localStorage.getItem("darkMode");
|
const hasStored = stored !== null;
|
||||||
const hasStored = stored !== null;
|
|
||||||
|
|
||||||
// 2) Determine initial mode
|
const isDark = hasStored
|
||||||
const isDark = hasStored
|
? (stored === "true")
|
||||||
? (stored === "true")
|
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
||||||
|
|
||||||
document.body.classList.toggle("dark-mode", isDark);
|
document.body.classList.toggle("dark-mode", isDark);
|
||||||
darkModeToggle.classList.toggle("active", isDark);
|
darkModeToggle.classList.toggle("active", isDark);
|
||||||
|
|
||||||
// 3) Helper to update icon & aria-label
|
function updateIcon() {
|
||||||
function updateIcon() {
|
const dark = document.body.classList.contains("dark-mode");
|
||||||
const dark = document.body.classList.contains("dark-mode");
|
darkModeIcon.textContent = dark ? "light_mode" : "dark_mode";
|
||||||
darkModeIcon.textContent = dark ? "light_mode" : "dark_mode";
|
darkModeToggle.setAttribute("aria-label", dark ? t("light_mode") : t("dark_mode"));
|
||||||
darkModeToggle.setAttribute(
|
darkModeToggle.setAttribute("title", dark ? t("switch_to_light_mode") : t("switch_to_dark_mode"));
|
||||||
"aria-label",
|
}
|
||||||
dark ? t("light_mode") : t("dark_mode")
|
|
||||||
);
|
|
||||||
darkModeToggle.setAttribute(
|
|
||||||
"title",
|
|
||||||
dark
|
|
||||||
? t("switch_to_light_mode")
|
|
||||||
: t("switch_to_dark_mode")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateIcon();
|
|
||||||
|
|
||||||
// 4) Click handler: always override and store preference
|
|
||||||
darkModeToggle.addEventListener("click", () => {
|
|
||||||
const nowDark = document.body.classList.toggle("dark-mode");
|
|
||||||
localStorage.setItem("darkMode", nowDark ? "true" : "false");
|
|
||||||
updateIcon();
|
updateIcon();
|
||||||
});
|
|
||||||
|
|
||||||
// 5) OS‐level change: only if no stored pref at load
|
darkModeToggle.addEventListener("click", () => {
|
||||||
if (!hasStored && window.matchMedia) {
|
const nowDark = document.body.classList.toggle("dark-mode");
|
||||||
window
|
localStorage.setItem("darkMode", nowDark ? "true" : "false");
|
||||||
.matchMedia("(prefers-color-scheme: dark)")
|
updateIcon();
|
||||||
.addEventListener("change", e => {
|
});
|
||||||
|
|
||||||
|
if (!hasStored && window.matchMedia) {
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => {
|
||||||
document.body.classList.toggle("dark-mode", e.matches);
|
document.body.classList.toggle("dark-mode", e.matches);
|
||||||
updateIcon();
|
updateIcon();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// --- End Dark Mode Persistence ---
|
||||||
// --- End Dark Mode Persistence ---
|
|
||||||
|
|
||||||
const message = sessionStorage.getItem("welcomeMessage");
|
const message = sessionStorage.getItem("welcomeMessage");
|
||||||
if (message) {
|
if (message) {
|
||||||
showToast(message);
|
showToast(message);
|
||||||
sessionStorage.removeItem("welcomeMessage");
|
sessionStorage.removeItem("welcomeMessage");
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
})
|
||||||
console.error("Initialization halted due to CSRF token load failure.", error);
|
.catch(error => {
|
||||||
});
|
console.error("Initialization halted due to CSRF token load failure.", error);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Auto-scroll During Drag ---
|
// --- Auto-scroll During Drag ---
|
||||||
// Adjust these values as needed:
|
const SCROLL_THRESHOLD = 50;
|
||||||
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling
|
const SCROLL_SPEED = 20;
|
||||||
const SCROLL_SPEED = 20; // pixels to scroll per event
|
|
||||||
|
|
||||||
document.addEventListener("dragover", function (e) {
|
document.addEventListener("dragover", function (e) {
|
||||||
if (e.clientY < SCROLL_THRESHOLD) {
|
if (e.clientY < SCROLL_THRESHOLD) {
|
||||||
window.scrollBy(0, -SCROLL_SPEED);
|
window.scrollBy(0, -SCROLL_SPEED);
|
||||||
|
|||||||
@@ -79,15 +79,16 @@ export function setupTrashRestoreDelete() {
|
|||||||
body: JSON.stringify({ files })
|
body: JSON.stringify({ files })
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(() => {
|
||||||
if (data.success) {
|
// Always report what we actually restored
|
||||||
showToast(data.success);
|
if (files.length === 1) {
|
||||||
toggleVisibility("restoreFilesModal", false);
|
showToast(`Restored file: ${files[0]}`);
|
||||||
loadFileList(window.currentFolder);
|
|
||||||
loadFolderTree(window.currentFolder);
|
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error);
|
showToast(`Restored files: ${files.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
toggleVisibility("restoreFilesModal", false);
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
loadFolderTree(window.currentFolder);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error restoring files:", err);
|
console.error("Error restoring files:", err);
|
||||||
@@ -119,16 +120,15 @@ export function setupTrashRestoreDelete() {
|
|||||||
body: JSON.stringify({ files })
|
body: JSON.stringify({ files })
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(() => {
|
||||||
if (data.success) {
|
if (files.length === 1) {
|
||||||
showToast(data.success);
|
showToast(`Restored file: ${files[0]}`);
|
||||||
toggleVisibility("restoreFilesModal", false);
|
|
||||||
loadFileList(window.currentFolder);
|
|
||||||
loadFolderTree(window.currentFolder);
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error);
|
showToast(`Restored files: ${files.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
toggleVisibility("restoreFilesModal", false);
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
loadFolderTree(window.currentFolder);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error restoring files:", err);
|
console.error("Error restoring files:", err);
|
||||||
|
|||||||
@@ -669,6 +669,18 @@ function submitFiles(allFiles) {
|
|||||||
}
|
}
|
||||||
allSucceeded = false;
|
allSucceeded = false;
|
||||||
}
|
}
|
||||||
|
if (file.isClipboard) {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.selectedFiles = [];
|
||||||
|
updateFileInfoCount();
|
||||||
|
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||||
|
if (progressContainer) progressContainer.innerHTML = "";
|
||||||
|
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
||||||
|
if (fileInfoContainer) {
|
||||||
|
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Only now count this chunk as finished ───────────────────
|
// ─── Only now count this chunk as finished ───────────────────
|
||||||
finishedCount++;
|
finishedCount++;
|
||||||
@@ -847,4 +859,39 @@ function initUpload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { initUpload };
|
export { initUpload };
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Clipboard Paste Handler (Mimics Drag-and-Drop)
|
||||||
|
// -------------------------
|
||||||
|
document.addEventListener('paste', function handlePasteUpload(e) {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
const ext = file.name.split('.').pop() || 'png';
|
||||||
|
const renamedFile = new File([file], `image${Date.now()}.${ext}`, { type: file.type });
|
||||||
|
renamedFile.isClipboard = true;
|
||||||
|
|
||||||
|
Object.defineProperty(renamedFile, 'customRelativePath', {
|
||||||
|
value: renamedFile.name,
|
||||||
|
writable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
files.push(renamedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
processFiles(files);
|
||||||
|
showToast('Pasted file added to upload list.', 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -13,56 +13,62 @@ if (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── 1) Bootstrap & load models ─────────────────────────────────────────────
|
// ─── 1) Bootstrap & load models ─────────────────────────────────────────────
|
||||||
require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
|
require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, loadUserPermissions(), etc.
|
||||||
require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV
|
require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV
|
||||||
require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole(), loadFolderPermission()
|
require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole()
|
||||||
require_once __DIR__ . '/../src/models/AdminModel.php'; // AdminModel::getConfig()
|
require_once __DIR__ . '/../src/models/AdminModel.php';// AdminModel::getConfig()
|
||||||
|
require_once __DIR__ . '/../src/lib/ACL.php'; // ACL checks
|
||||||
|
require_once __DIR__ . '/../src/webdav/CurrentUser.php';
|
||||||
|
|
||||||
// ─── 1.1) Global WebDAV feature toggle ──────────────────────────────────────
|
// ─── 1.1) Global WebDAV feature toggle ──────────────────────────────────────
|
||||||
$adminConfig = AdminModel::getConfig();
|
$adminConfig = AdminModel::getConfig();
|
||||||
$enableWebDAV = isset($adminConfig['enableWebDAV']) && $adminConfig['enableWebDAV'];
|
$enableWebDAV = isset($adminConfig['enableWebDAV']) && $adminConfig['enableWebDAV'];
|
||||||
if (!$enableWebDAV) {
|
if (!$enableWebDAV) {
|
||||||
header('HTTP/1.1 403 Forbidden');
|
header('HTTP/1.1 403 Forbidden');
|
||||||
echo 'WebDAV access is currently disabled by administrator.';
|
echo 'WebDAV access is currently disabled by administrator.';
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 2) Load WebDAV directory implementation ──────────────────────────
|
// ─── 2) Load WebDAV directory implementation (ACL-aware) ────────────────────
|
||||||
require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php';
|
require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php';
|
||||||
|
|
||||||
use Sabre\DAV\Server;
|
use Sabre\DAV\Server;
|
||||||
use Sabre\DAV\Auth\Backend\BasicCallBack;
|
use Sabre\DAV\Auth\Backend\BasicCallBack;
|
||||||
use Sabre\DAV\Auth\Plugin as AuthPlugin;
|
use Sabre\DAV\Auth\Plugin as AuthPlugin;
|
||||||
use Sabre\DAV\Locks\Plugin as LocksPlugin;
|
use Sabre\DAV\Locks\Plugin as LocksPlugin;
|
||||||
use Sabre\DAV\Locks\Backend\File as LocksFileBackend;
|
use Sabre\DAV\Locks\Backend\File as LocksFileBackend;
|
||||||
use FileRise\WebDAV\FileRiseDirectory;
|
use FileRise\WebDAV\FileRiseDirectory;
|
||||||
|
use FileRise\WebDAV\CurrentUser;
|
||||||
|
|
||||||
// ─── 3) HTTP‑Basic backend ─────────────────────────────────────────────────
|
// ─── 3) HTTP-Basic backend (delegates to your AuthModel) ────────────────────
|
||||||
$authBackend = new BasicCallBack(function(string $user, string $pass) {
|
$authBackend = new BasicCallBack(function(string $user, string $pass) {
|
||||||
return \AuthModel::authenticate($user, $pass) !== false;
|
return \AuthModel::authenticate($user, $pass) !== false;
|
||||||
});
|
});
|
||||||
$authPlugin = new AuthPlugin($authBackend, 'FileRise');
|
$authPlugin = new AuthPlugin($authBackend, 'FileRise');
|
||||||
|
|
||||||
// ─── 4) Determine user scope ────────────────────────────────────────────────
|
// ─── 4) Resolve authenticated user + perms ──────────────────────────────────
|
||||||
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
|
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
|
||||||
$isAdmin = (\AuthModel::getUserRole($user) === '1');
|
if ($user === '') {
|
||||||
$folderOnly = (bool)\AuthModel::loadFolderPermission($user);
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
|
header('WWW-Authenticate: Basic realm="FileRise"');
|
||||||
if ($isAdmin || !$folderOnly) {
|
echo 'Authentication required.';
|
||||||
// Admins (or users without folder-only restriction) see the full /uploads
|
exit;
|
||||||
$rootPath = rtrim(UPLOAD_DIR, '/\\');
|
|
||||||
} else {
|
|
||||||
// Folder‑only users see only /uploads/{username}
|
|
||||||
$rootPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $user;
|
|
||||||
if (!is_dir($rootPath)) {
|
|
||||||
mkdir($rootPath, 0755, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 5) Spin up SabreDAV ────────────────────────────────────────────────────
|
$perms = is_callable('loadUserPermissions') ? (loadUserPermissions($user) ?: []) : [];
|
||||||
|
$isAdmin = (\AuthModel::getUserRole($user) === '1');
|
||||||
|
|
||||||
|
// set for metadata attribution in WebDAV writes
|
||||||
|
CurrentUser::set($user);
|
||||||
|
|
||||||
|
// ─── 5) Mount the real uploads root; ACL filters everything at node level ───
|
||||||
|
$rootPath = rtrim(UPLOAD_DIR, '/\\');
|
||||||
|
|
||||||
$server = new Server([
|
$server = new Server([
|
||||||
new FileRiseDirectory($rootPath, $user, $folderOnly),
|
new FileRiseDirectory($rootPath, $user, $isAdmin, $perms),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Auth + Locks
|
||||||
$server->addPlugin($authPlugin);
|
$server->addPlugin($authPlugin);
|
||||||
$server->addPlugin(
|
$server->addPlugin(
|
||||||
new LocksPlugin(
|
new LocksPlugin(
|
||||||
@@ -70,5 +76,8 @@ $server->addPlugin(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Base URI (adjust if you serve from a subdir or rewrite rule)
|
||||||
$server->setBaseUri('/webdav.php/');
|
$server->setBaseUri('/webdav.php/');
|
||||||
|
|
||||||
|
// Execute
|
||||||
$server->exec();
|
$server->exec();
|
||||||
143
scripts/scan_uploads.php
Normal file
143
scripts/scan_uploads.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* scan_uploads.php
|
||||||
|
* Rebuild/repair per-folder metadata used by FileRise models.
|
||||||
|
* - Uses UPLOAD_DIR / META_DIR / DATE_TIME_FORMAT from config.php
|
||||||
|
* - Per-folder metadata naming matches FileModel/FolderModel:
|
||||||
|
* "root" -> root_metadata.json
|
||||||
|
* "<sub/dir>" -> str_replace(['/', '\\', ' '], '-', '<sub/dir>') . '_metadata.json'
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
// ---------- helpers that mirror model behavior ----------
|
||||||
|
|
||||||
|
/** Compute the metadata JSON path for a folder key (e.g., "root", "invoices/2025"). */
|
||||||
|
function folder_metadata_path(string $folderKey): string {
|
||||||
|
if (strtolower(trim($folderKey)) === 'root' || trim($folderKey) === '') {
|
||||||
|
return rtrim(META_DIR, '/\\') . '/root_metadata.json';
|
||||||
|
}
|
||||||
|
$safe = str_replace(['/', '\\', ' '], '-', trim($folderKey));
|
||||||
|
return rtrim(META_DIR, '/\\') . '/' . $safe . '_metadata.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn an absolute path under UPLOAD_DIR into a folder key (“root” or relative with slashes). */
|
||||||
|
function to_folder_key(string $absPath): string {
|
||||||
|
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
|
if (realpath($absPath) === realpath(rtrim(UPLOAD_DIR, '/\\'))) {
|
||||||
|
return 'root';
|
||||||
|
}
|
||||||
|
$rel = ltrim(str_replace('\\', '/', substr($absPath, strlen($base))), '/');
|
||||||
|
return $rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List immediate files in a directory (no subdirs). */
|
||||||
|
function list_files(string $dir): array {
|
||||||
|
$out = [];
|
||||||
|
$entries = @scandir($dir);
|
||||||
|
if ($entries === false) return $out;
|
||||||
|
foreach ($entries as $name) {
|
||||||
|
if ($name === '.' || $name === '..') continue;
|
||||||
|
$p = $dir . DIRECTORY_SEPARATOR . $name;
|
||||||
|
if (is_file($p)) $out[] = $name;
|
||||||
|
}
|
||||||
|
sort($out, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively list subfolders (relative folder keys), skipping trash/. */
|
||||||
|
function list_all_folders(string $root): array {
|
||||||
|
$root = rtrim($root, '/\\');
|
||||||
|
$folders = ['root'];
|
||||||
|
$it = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::SELF_FIRST
|
||||||
|
);
|
||||||
|
foreach ($it as $path => $info) {
|
||||||
|
if ($info->isDir()) {
|
||||||
|
// relative key like "foo/bar"
|
||||||
|
$rel = ltrim(str_replace(['\\'], '/', substr($path, strlen($root) + 1)), '/');
|
||||||
|
if ($rel === '') continue;
|
||||||
|
// skip trash and profile_pics subtrees
|
||||||
|
if ($rel === 'trash' || strpos($rel, 'trash/') === 0) continue;
|
||||||
|
if ($rel === 'profile_pics' || strpos($rel, 'profile_pics/') === 0) continue;
|
||||||
|
// obey the app’s folder-name regex to stay consistent
|
||||||
|
if (preg_match(REGEX_FOLDER_NAME, basename($rel))) {
|
||||||
|
$folders[] = $rel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// de-dup and sort
|
||||||
|
$folders = array_values(array_unique($folders));
|
||||||
|
sort($folders, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
return $folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- main ----------
|
||||||
|
|
||||||
|
$uploads = rtrim(UPLOAD_DIR, '/\\');
|
||||||
|
$metaDir = rtrim(META_DIR, '/\\');
|
||||||
|
|
||||||
|
// Ensure metadata dir exists
|
||||||
|
if (!is_dir($metaDir)) {
|
||||||
|
@mkdir($metaDir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date(DATE_TIME_FORMAT);
|
||||||
|
$folders = list_all_folders($uploads);
|
||||||
|
|
||||||
|
$totalCreated = 0;
|
||||||
|
$totalPruned = 0;
|
||||||
|
|
||||||
|
foreach ($folders as $folderKey) {
|
||||||
|
$absFolder = ($folderKey === 'root')
|
||||||
|
? $uploads
|
||||||
|
: $uploads . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folderKey);
|
||||||
|
|
||||||
|
if (!is_dir($absFolder)) continue;
|
||||||
|
|
||||||
|
$files = list_files($absFolder);
|
||||||
|
|
||||||
|
$metaPath = folder_metadata_path($folderKey);
|
||||||
|
$metadata = [];
|
||||||
|
if (is_file($metaPath)) {
|
||||||
|
$decoded = json_decode(@file_get_contents($metaPath), true);
|
||||||
|
if (is_array($decoded)) $metadata = $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a quick lookup of existing entries
|
||||||
|
$existing = array_keys($metadata);
|
||||||
|
|
||||||
|
// ADD missing files
|
||||||
|
foreach ($files as $name) {
|
||||||
|
// Keep same filename validation used in FileModel
|
||||||
|
if (!preg_match(REGEX_FILE_NAME, $name)) continue;
|
||||||
|
|
||||||
|
if (!isset($metadata[$name])) {
|
||||||
|
$metadata[$name] = [
|
||||||
|
'uploaded' => $now,
|
||||||
|
'modified' => $now,
|
||||||
|
'uploader' => 'Imported'
|
||||||
|
];
|
||||||
|
$totalCreated++;
|
||||||
|
echo "Indexed: " . ($folderKey === 'root' ? '' : $folderKey . '/') . $name . PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRUNE stale metadata entries for files that no longer exist
|
||||||
|
foreach ($existing as $name) {
|
||||||
|
if (!in_array($name, $files, true)) {
|
||||||
|
unset($metadata[$name]);
|
||||||
|
$totalPruned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent dir exists and write metadata
|
||||||
|
@mkdir(dirname($metaPath), 0775, true);
|
||||||
|
if (@file_put_contents($metaPath, json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
|
||||||
|
fwrite(STDERR, "Failed to write metadata for folder: {$folderKey}\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Done. Created {$totalCreated} entr" . ($totalCreated === 1 ? "y" : "ies") .
|
||||||
|
", pruned {$totalPruned}.\n";
|
||||||
@@ -53,24 +53,39 @@ class AdminController
|
|||||||
public function getConfig(): void
|
public function getConfig(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Require authenticated admin to read config (prevents information disclosure)
|
||||||
|
if (
|
||||||
|
empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||||
|
empty($_SESSION['isAdmin'])
|
||||||
|
) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Unauthorized access.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$config = AdminModel::getConfig();
|
$config = AdminModel::getConfig();
|
||||||
|
|
||||||
if (isset($config['error'])) {
|
if (isset($config['error'])) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => $config['error']]);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($config['loginOptions']) || !is_array($config['loginOptions'])) {
|
// Build a safe subset for the front-end
|
||||||
$config['loginOptions'] = [];
|
$safe = [
|
||||||
}
|
'header_title' => $config['header_title'] ?? '',
|
||||||
if (!array_key_exists('authBypass', $config['loginOptions'])) {
|
'loginOptions' => $config['loginOptions'] ?? [],
|
||||||
$config['loginOptions']['authBypass'] = false;
|
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
|
||||||
}
|
'enableWebDAV' => $config['enableWebDAV'] ?? false,
|
||||||
if (!array_key_exists('authHeaderName', $config['loginOptions'])) {
|
'sharedMaxUploadSize' => $config['sharedMaxUploadSize'] ?? 0,
|
||||||
$config['loginOptions']['authHeaderName'] = 'X-Remote-User';
|
'oidc' => [
|
||||||
}
|
'providerUrl' => $config['oidc']['providerUrl'] ?? '',
|
||||||
// ← END INSERT
|
'redirectUri' => $config['oidc']['redirectUri'] ?? '',
|
||||||
|
// clientSecret and clientId never exposed here
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
echo json_encode($config);
|
echo json_encode($safe);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,116 +151,183 @@ class AdminController
|
|||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
// Ensure the user is authenticated and is an admin.
|
// —– auth & CSRF checks —–
|
||||||
if (
|
if (
|
||||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
|
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
|
||||||
) {
|
) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['error' => 'Unauthorized access.']);
|
echo json_encode(['error' => 'Unauthorized access.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||||
// Validate CSRF token.
|
$receivedToken = trim($headersArr['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
||||||
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
||||||
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['error' => 'Invalid CSRF token.']);
|
echo json_encode(['error' => 'Invalid CSRF token.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve and decode JSON input.
|
// —– fetch payload —–
|
||||||
$input = file_get_contents('php://input');
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
$data = json_decode($input, true);
|
|
||||||
if (!is_array($data)) {
|
if (!is_array($data)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'Invalid input.']);
|
echo json_encode(['error' => 'Invalid input.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare existing settings
|
// —– load existing on-disk config —–
|
||||||
$headerTitle = isset($data['header_title']) ? trim($data['header_title']) : "";
|
$existing = AdminModel::getConfig();
|
||||||
$oidc = isset($data['oidc']) ? $data['oidc'] : [];
|
if (isset($existing['error'])) {
|
||||||
$oidcProviderUrl = isset($oidc['providerUrl']) ? filter_var($oidc['providerUrl'], FILTER_SANITIZE_URL) : '';
|
http_response_code(500);
|
||||||
$oidcClientId = isset($oidc['clientId']) ? trim($oidc['clientId']) : '';
|
echo json_encode(['error' => $existing['error']]);
|
||||||
$oidcClientSecret = isset($oidc['clientSecret']) ? trim($oidc['clientSecret']) : '';
|
|
||||||
$oidcRedirectUri = isset($oidc['redirectUri']) ? filter_var($oidc['redirectUri'], FILTER_SANITIZE_URL) : '';
|
|
||||||
if (!$oidcProviderUrl || !$oidcClientId || !$oidcClientSecret || !$oidcRedirectUri) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['error' => 'Incomplete OIDC configuration.']);
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$disableFormLogin = false;
|
// —– start merge with existing as base —–
|
||||||
if (isset($data['loginOptions']['disableFormLogin'])) {
|
// Ensure minimal structure if the file was partially missing.
|
||||||
$disableFormLogin = filter_var($data['loginOptions']['disableFormLogin'], FILTER_VALIDATE_BOOLEAN);
|
$merged = $existing + [
|
||||||
} elseif (isset($data['disableFormLogin'])) {
|
'header_title' => '',
|
||||||
$disableFormLogin = filter_var($data['disableFormLogin'], FILTER_VALIDATE_BOOLEAN);
|
'loginOptions' => [
|
||||||
}
|
'disableFormLogin' => false,
|
||||||
$disableBasicAuth = false;
|
'disableBasicAuth' => false,
|
||||||
if (isset($data['loginOptions']['disableBasicAuth'])) {
|
'disableOIDCLogin' => true,
|
||||||
$disableBasicAuth = filter_var($data['loginOptions']['disableBasicAuth'], FILTER_VALIDATE_BOOLEAN);
|
'authBypass' => false,
|
||||||
} elseif (isset($data['disableBasicAuth'])) {
|
'authHeaderName' => 'X-Remote-User'
|
||||||
$disableBasicAuth = filter_var($data['disableBasicAuth'], FILTER_VALIDATE_BOOLEAN);
|
|
||||||
}
|
|
||||||
|
|
||||||
$disableOIDCLogin = false;
|
|
||||||
if (isset($data['loginOptions']['disableOIDCLogin'])) {
|
|
||||||
$disableOIDCLogin = filter_var($data['loginOptions']['disableOIDCLogin'], FILTER_VALIDATE_BOOLEAN);
|
|
||||||
} elseif (isset($data['disableOIDCLogin'])) {
|
|
||||||
$disableOIDCLogin = filter_var($data['disableOIDCLogin'], FILTER_VALIDATE_BOOLEAN);
|
|
||||||
}
|
|
||||||
$globalOtpauthUrl = isset($data['globalOtpauthUrl']) ? trim($data['globalOtpauthUrl']) : "";
|
|
||||||
|
|
||||||
// ── NEW: enableWebDAV flag ──────────────────────────────────────
|
|
||||||
$enableWebDAV = false;
|
|
||||||
if (array_key_exists('enableWebDAV', $data)) {
|
|
||||||
$enableWebDAV = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
|
|
||||||
} elseif (isset($data['features']['enableWebDAV'])) {
|
|
||||||
$enableWebDAV = filter_var($data['features']['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── NEW: sharedMaxUploadSize ──────────────────────────────────────
|
|
||||||
$sharedMaxUploadSize = null;
|
|
||||||
if (array_key_exists('sharedMaxUploadSize', $data)) {
|
|
||||||
$sharedMaxUploadSize = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
|
|
||||||
} elseif (isset($data['features']['sharedMaxUploadSize'])) {
|
|
||||||
$sharedMaxUploadSize = filter_var($data['features']['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
|
|
||||||
}
|
|
||||||
|
|
||||||
$authBypass = filter_var(
|
|
||||||
$data['loginOptions']['authBypass'] ?? false,
|
|
||||||
FILTER_VALIDATE_BOOLEAN
|
|
||||||
);
|
|
||||||
$authHeaderName = trim($data['loginOptions']['authHeaderName'] ?? '') ?: 'X-Remote-User';
|
|
||||||
|
|
||||||
$configUpdate = [
|
|
||||||
'header_title' => $headerTitle,
|
|
||||||
'oidc' => [
|
|
||||||
'providerUrl' => $oidcProviderUrl,
|
|
||||||
'clientId' => $oidcClientId,
|
|
||||||
'clientSecret' => $oidcClientSecret,
|
|
||||||
'redirectUri' => $oidcRedirectUri,
|
|
||||||
],
|
],
|
||||||
'loginOptions' => [
|
'globalOtpauthUrl' => '',
|
||||||
'disableFormLogin' => $disableFormLogin,
|
'enableWebDAV' => false,
|
||||||
'disableBasicAuth' => $disableBasicAuth,
|
'sharedMaxUploadSize' => 0,
|
||||||
'disableOIDCLogin' => $disableOIDCLogin,
|
'oidc' => [
|
||||||
'authBypass' => $authBypass,
|
'providerUrl' => '',
|
||||||
'authHeaderName' => $authHeaderName,
|
'clientId' => '',
|
||||||
|
'clientSecret'=> '',
|
||||||
|
'redirectUri' => ''
|
||||||
],
|
],
|
||||||
'globalOtpauthUrl' => $globalOtpauthUrl,
|
|
||||||
'enableWebDAV' => $enableWebDAV,
|
|
||||||
'sharedMaxUploadSize' => $sharedMaxUploadSize // ← NEW
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Delegate to the model.
|
// header_title (cap length and strip control chars)
|
||||||
$result = AdminModel::updateConfig($configUpdate);
|
if (array_key_exists('header_title', $data)) {
|
||||||
|
$title = trim((string)$data['header_title']);
|
||||||
|
$title = preg_replace('/[\x00-\x1F\x7F]/', '', $title);
|
||||||
|
if (mb_strlen($title) > 100) { // hard cap
|
||||||
|
$title = mb_substr($title, 0, 100);
|
||||||
|
}
|
||||||
|
$merged['header_title'] = $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginOptions: inherit existing then override if provided
|
||||||
|
foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) {
|
||||||
|
if (isset($data['loginOptions'][$flag])) {
|
||||||
|
$merged['loginOptions'][$flag] = filter_var(
|
||||||
|
$data['loginOptions'][$flag],
|
||||||
|
FILTER_VALIDATE_BOOLEAN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($data['loginOptions']['authHeaderName'])) {
|
||||||
|
$hdr = trim((string)$data['loginOptions']['authHeaderName']);
|
||||||
|
// very restrictive header-name pattern: letters, numbers, dashes
|
||||||
|
if ($hdr !== '' && preg_match('/^[A-Za-z0-9\-]+$/', $hdr)) {
|
||||||
|
$merged['loginOptions']['authHeaderName'] = $hdr;
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Invalid authHeaderName.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// globalOtpauthUrl
|
||||||
|
if (array_key_exists('globalOtpauthUrl', $data)) {
|
||||||
|
$merged['globalOtpauthUrl'] = trim((string)$data['globalOtpauthUrl']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// enableWebDAV
|
||||||
|
if (array_key_exists('enableWebDAV', $data)) {
|
||||||
|
$merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sharedMaxUploadSize
|
||||||
|
if (array_key_exists('sharedMaxUploadSize', $data)) {
|
||||||
|
$sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
|
||||||
|
if ($sms === false || $sms < 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'sharedMaxUploadSize must be a non-negative integer (bytes).']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// Clamp to PHP limits to avoid confusing UX
|
||||||
|
$maxPost = self::iniToBytes(ini_get('post_max_size'));
|
||||||
|
$maxFile = self::iniToBytes(ini_get('upload_max_filesize'));
|
||||||
|
$phpCap = min($maxPost ?: PHP_INT_MAX, $maxFile ?: PHP_INT_MAX);
|
||||||
|
if ($phpCap !== PHP_INT_MAX && $sms > $phpCap) {
|
||||||
|
$sms = $phpCap;
|
||||||
|
}
|
||||||
|
$merged['sharedMaxUploadSize'] = $sms;
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidc: only overwrite non-empty inputs; validate when enabling OIDC
|
||||||
|
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) {
|
||||||
|
if (!empty($data['oidc'][$f])) {
|
||||||
|
$val = trim((string)$data['oidc'][$f]);
|
||||||
|
if ($f === 'providerUrl' || $f === 'redirectUri') {
|
||||||
|
$val = filter_var($val, FILTER_SANITIZE_URL);
|
||||||
|
}
|
||||||
|
$merged['oidc'][$f] = $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If OIDC login is enabled, ensure required fields are present and sane
|
||||||
|
$oidcEnabled = !empty($merged['loginOptions']['disableOIDCLogin']) ? false : true;
|
||||||
|
if ($oidcEnabled) {
|
||||||
|
$prov = $merged['oidc']['providerUrl'] ?? '';
|
||||||
|
$rid = $merged['oidc']['redirectUri'] ?? '';
|
||||||
|
$cid = $merged['oidc']['clientId'] ?? '';
|
||||||
|
// clientSecret may be empty for some PKCE-only flows, but commonly needed for code flow.
|
||||||
|
if ($prov === '' || $rid === '' || $cid === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'OIDC is enabled but providerUrl, redirectUri, and clientId are required.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// Require https except for localhost development
|
||||||
|
$httpsOk = function(string $url): bool {
|
||||||
|
if ($url === '') return false;
|
||||||
|
$parts = parse_url($url);
|
||||||
|
if (!$parts || empty($parts['scheme'])) return false;
|
||||||
|
if ($parts['scheme'] === 'https') return true;
|
||||||
|
if ($parts['scheme'] === 'http' && (isset($parts['host']) && ($parts['host'] === 'localhost' || $parts['host'] === '127.0.0.1'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if (!$httpsOk($prov) || !$httpsOk($rid)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'providerUrl and redirectUri must be https (or http on localhost)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —– persist merged config —–
|
||||||
|
$result = AdminModel::updateConfig($merged);
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
}
|
}
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/** Convert php.ini shorthand like "128M" to bytes */
|
||||||
|
private static function iniToBytes($val)
|
||||||
|
{
|
||||||
|
if ($val === false || $val === null || $val === '') return 0;
|
||||||
|
$val = trim((string)$val);
|
||||||
|
$last = strtolower($val[strlen($val)-1]);
|
||||||
|
$num = (int)$val;
|
||||||
|
switch ($last) {
|
||||||
|
case 'g': $num *= 1024;
|
||||||
|
case 'm': $num *= 1024;
|
||||||
|
case 'k': $num *= 1024;
|
||||||
|
}
|
||||||
|
return $num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -111,6 +111,8 @@ class AuthController
|
|||||||
$cfg['oidc']['clientSecret']
|
$cfg['oidc']['clientSecret']
|
||||||
);
|
);
|
||||||
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
|
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
|
||||||
|
$oidc->addScope(['openid','profile','email']);
|
||||||
|
|
||||||
|
|
||||||
if ($oidcAction === 'callback') {
|
if ($oidcAction === 'callback') {
|
||||||
try {
|
try {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
// src/controllers/UploadController.php
|
// src/controllers/UploadController.php
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
|
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
|
||||||
|
|
||||||
class UploadController {
|
class UploadController {
|
||||||
@@ -72,69 +73,80 @@ class UploadController {
|
|||||||
*/
|
*/
|
||||||
public function handleUpload(): void {
|
public function handleUpload(): void {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
//
|
// ---- 1) CSRF (header or form field) ----
|
||||||
// 1) CSRF – pull from header or POST fields
|
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
|
||||||
//
|
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
||||||
$received = '';
|
$received = '';
|
||||||
if (!empty($headersArr['x-csrf-token'])) {
|
if (!empty($headersArr['x-csrf-token'])) {
|
||||||
$received = trim($headersArr['x-csrf-token']);
|
$received = trim($headersArr['x-csrf-token']);
|
||||||
} elseif (!empty($_POST['csrf_token'])) {
|
} elseif (!empty($_POST['csrf_token'])) {
|
||||||
$received = trim($_POST['csrf_token']);
|
$received = trim($_POST['csrf_token']);
|
||||||
} elseif (!empty($_POST['upload_token'])) {
|
} elseif (!empty($_POST['upload_token'])) {
|
||||||
|
// legacy alias
|
||||||
$received = trim($_POST['upload_token']);
|
$received = trim($_POST['upload_token']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1a) If it doesn’t match, soft-fail: send new token and let client retry
|
|
||||||
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
|
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
|
||||||
// regenerate
|
// Soft-fail so client can retry with refreshed token
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
// tell client “please retry with this new token”
|
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'csrf_expired' => true,
|
'csrf_expired' => true,
|
||||||
'csrf_token' => $_SESSION['csrf_token']
|
'csrf_token' => $_SESSION['csrf_token']
|
||||||
]);
|
]);
|
||||||
exit;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// ---- 2) Auth + account-level flags ----
|
||||||
// 2) Auth checks
|
if (empty($_SESSION['authenticated'])) {
|
||||||
//
|
|
||||||
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(["error" => "Unauthorized"]);
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
exit;
|
return;
|
||||||
}
|
|
||||||
$userPerms = loadUserPermissions($_SESSION['username']);
|
|
||||||
if (!empty($userPerms['disableUpload'])) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(["error" => "Upload disabled for this user."]);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
// 3) Delegate the actual file handling
|
$userPerms = loadUserPermissions($username) ?: [];
|
||||||
//
|
$isAdmin = ACL::isAdmin($userPerms);
|
||||||
|
|
||||||
|
// Admins should never be blocked by account-level "disableUpload"
|
||||||
|
if (!$isAdmin && !empty($userPerms['disableUpload'])) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Upload disabled for this user.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 3) Folder-level WRITE permission (ACL) ----
|
||||||
|
// Always require client to send the folder; fall back to GET if needed.
|
||||||
|
$folderParam = isset($_POST['folder']) ? (string)$_POST['folder'] : (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
|
||||||
|
$targetFolder = ACL::normalizeFolder($folderParam);
|
||||||
|
|
||||||
|
// Admins bypass folder canWrite checks
|
||||||
|
if (!$isAdmin && !ACL::canWrite($username, $userPerms, $targetFolder)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 4) Delegate to model (actual file/chunk processing) ----
|
||||||
|
// (Optionally re-check in UploadModel before finalizing.)
|
||||||
$result = UploadModel::handleUpload($_POST, $_FILES);
|
$result = UploadModel::handleUpload($_POST, $_FILES);
|
||||||
|
|
||||||
//
|
// ---- 5) Response ----
|
||||||
// 4) Respond
|
|
||||||
//
|
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
exit;
|
return;
|
||||||
}
|
}
|
||||||
if (isset($result['status'])) {
|
if (isset($result['status'])) {
|
||||||
|
// e.g., {"status":"chunk uploaded"}
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
exit;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// full‐upload redirect
|
echo json_encode([
|
||||||
$_SESSION['upload_message'] = "File uploaded successfully.";
|
'success' => 'File uploaded successfully',
|
||||||
exit;
|
'newFilename' => $result['newFilename'] ?? null
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,25 +187,22 @@ class UploadController {
|
|||||||
*/
|
*/
|
||||||
public function removeChunks(): void {
|
public function removeChunks(): void {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
// CSRF Protection: Validate token from POST data.
|
|
||||||
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
|
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
|
||||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
echo json_encode(['error' => 'Invalid CSRF token']);
|
||||||
exit;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that the folder parameter is provided.
|
|
||||||
if (!isset($_POST['folder'])) {
|
if (!isset($_POST['folder'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(["error" => "No folder specified"]);
|
echo json_encode(['error' => 'No folder specified']);
|
||||||
exit;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$folder = $_POST['folder'];
|
$folder = (string)$_POST['folder'];
|
||||||
$result = UploadModel::removeChunks($folder);
|
$result = UploadModel::removeChunks($folder);
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
347
src/lib/ACL.php
Normal file
347
src/lib/ACL.php
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
<?php
|
||||||
|
// src/lib/ACL.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
|
||||||
|
class ACL
|
||||||
|
{
|
||||||
|
/** In-memory cache of the ACL file. */
|
||||||
|
private static $cache = null;
|
||||||
|
/** Absolute path to folder_acl.json */
|
||||||
|
private static $path = null;
|
||||||
|
|
||||||
|
/** Capability buckets we store per folder. */
|
||||||
|
private const BUCKETS = ['owners','read','write','share','read_own']; // + read_own (view own only)
|
||||||
|
|
||||||
|
/** Compute/cache the ACL storage path. */
|
||||||
|
private static function path(): string {
|
||||||
|
if (!self::$path) {
|
||||||
|
self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||||
|
}
|
||||||
|
return self::$path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize folder names (slashes + root). */
|
||||||
|
public static function normalizeFolder(string $f): string {
|
||||||
|
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
|
||||||
|
if ($f === '' || $f === 'root') return 'root';
|
||||||
|
return $f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function purgeUser(string $user): bool {
|
||||||
|
$user = (string)$user;
|
||||||
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
|
$changed = false;
|
||||||
|
|
||||||
|
foreach ($acl['folders'] as $folder => &$rec) {
|
||||||
|
foreach (self::BUCKETS as $k) {
|
||||||
|
$before = $rec[$k] ?? [];
|
||||||
|
$rec[$k] = array_values(array_filter($before, fn($u) => strcasecmp((string)$u, $user) !== 0));
|
||||||
|
if ($rec[$k] !== $before) $changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($rec);
|
||||||
|
|
||||||
|
return $changed ? self::save($acl) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load ACL fresh from disk, create/heal if needed. */
|
||||||
|
private static function loadFresh(): array {
|
||||||
|
$path = self::path();
|
||||||
|
|
||||||
|
if (!is_file($path)) {
|
||||||
|
@mkdir(dirname($path), 0755, true);
|
||||||
|
$init = [
|
||||||
|
'folders' => [
|
||||||
|
'root' => [
|
||||||
|
'owners' => ['admin'],
|
||||||
|
'read' => ['admin'],
|
||||||
|
'write' => ['admin'],
|
||||||
|
'share' => ['admin'],
|
||||||
|
'read_own'=> [], // new bucket; empty by default
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'groups' => [],
|
||||||
|
];
|
||||||
|
@file_put_contents($path, json_encode($init, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = (string) @file_get_contents($path);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
if (!is_array($data)) $data = [];
|
||||||
|
|
||||||
|
// Normalize shape
|
||||||
|
$data['folders'] = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
|
||||||
|
$data['groups'] = isset($data['groups']) && is_array($data['groups']) ? $data['groups'] : [];
|
||||||
|
|
||||||
|
// Ensure root exists and has all buckets
|
||||||
|
if (!isset($data['folders']['root']) || !is_array($data['folders']['root'])) {
|
||||||
|
$data['folders']['root'] = [
|
||||||
|
'owners' => ['admin'],
|
||||||
|
'read' => ['admin'],
|
||||||
|
'write' => ['admin'],
|
||||||
|
'share' => ['admin'],
|
||||||
|
'read_own' => [],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
foreach (self::BUCKETS as $k) {
|
||||||
|
if (!isset($data['folders']['root'][$k]) || !is_array($data['folders']['root'][$k])) {
|
||||||
|
// sensible defaults: admin in the classic buckets, empty for read_own
|
||||||
|
$data['folders']['root'][$k] = ($k === 'read_own') ? [] : ['admin'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heal any folder records
|
||||||
|
$healed = false;
|
||||||
|
foreach ($data['folders'] as $folder => &$rec) {
|
||||||
|
if (!is_array($rec)) { $rec = []; $healed = true; }
|
||||||
|
foreach (self::BUCKETS as $k) {
|
||||||
|
$v = $rec[$k] ?? [];
|
||||||
|
if (!is_array($v)) { $v = []; $healed = true; }
|
||||||
|
$v = array_values(array_unique(array_map('strval', $v)));
|
||||||
|
if (($rec[$k] ?? null) !== $v) { $rec[$k] = $v; $healed = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($rec);
|
||||||
|
|
||||||
|
self::$cache = $data;
|
||||||
|
|
||||||
|
// Persist back if we healed anything
|
||||||
|
if ($healed) {
|
||||||
|
@file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist ACL to disk and refresh cache. */
|
||||||
|
private static function save(array $acl): bool {
|
||||||
|
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
|
||||||
|
if ($ok) self::$cache = $acl;
|
||||||
|
return $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a bucket list (owners/read/write/share/read_own) for a folder (explicit only). */
|
||||||
|
private static function listFor(string $folder, string $key): array {
|
||||||
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
|
$f = $acl['folders'][$folder] ?? null;
|
||||||
|
return is_array($f[$key] ?? null) ? $f[$key] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure a folder record exists (giving an initial owner). */
|
||||||
|
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
|
if (!isset($acl['folders'][$folder])) {
|
||||||
|
$acl['folders'][$folder] = [
|
||||||
|
'owners' => [$owner],
|
||||||
|
'read' => [$owner],
|
||||||
|
'write' => [$owner],
|
||||||
|
'share' => [$owner],
|
||||||
|
'read_own' => [],
|
||||||
|
];
|
||||||
|
self::save($acl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this request is admin. */
|
||||||
|
public static function isAdmin(array $perms = []): bool {
|
||||||
|
if (!empty($_SESSION['isAdmin'])) return true;
|
||||||
|
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
|
||||||
|
if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
|
||||||
|
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
|
||||||
|
// Optional: if you configured DEFAULT_ADMIN_USER, treat that username as admin
|
||||||
|
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
|
||||||
|
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive membership in a capability bucket. $cap: owner|owners|read|write|share|read_own */
|
||||||
|
public static function hasGrant(string $user, string $folder, string $cap): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
$capKey = ($cap === 'owner') ? 'owners' : $cap;
|
||||||
|
$arr = self::listFor($folder, $capKey);
|
||||||
|
foreach ($arr as $u) {
|
||||||
|
if (strcasecmp((string)$u, $user) === 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if user is an explicit owner (or admin). */
|
||||||
|
public static function isOwner(string $user, array $perms, string $folder): bool {
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Manage" in UI == owner. */
|
||||||
|
public static function canManage(string $user, array $perms, string $folder): bool {
|
||||||
|
return self::isOwner($user, $perms, $folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canRead(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
// IMPORTANT: write no longer implies read
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'read');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Own-only view = read_own OR (any full view). */
|
||||||
|
public static function canReadOwn(string $user, array $perms, string $folder): bool {
|
||||||
|
// if they can full-view, this is trivially true
|
||||||
|
if (self::canRead($user, $perms, $folder)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'read_own');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload = write OR owner. No bypassOwnership. */
|
||||||
|
public static function canWrite(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share = share OR owner. No bypassOwnership. */
|
||||||
|
public static function canShare(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'share');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return explicit lists for a folder (no inheritance).
|
||||||
|
* Keys: owners, read, write, share, read_own (always arrays).
|
||||||
|
*/
|
||||||
|
public static function explicit(string $folder): array {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
|
$rec = $acl['folders'][$folder] ?? [];
|
||||||
|
$norm = function ($v): array {
|
||||||
|
if (!is_array($v)) return [];
|
||||||
|
$v = array_map('strval', $v);
|
||||||
|
return array_values(array_unique($v));
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
'owners' => $norm($rec['owners'] ?? []),
|
||||||
|
'read' => $norm($rec['read'] ?? []),
|
||||||
|
'write' => $norm($rec['write'] ?? []),
|
||||||
|
'share' => $norm($rec['share'] ?? []),
|
||||||
|
'read_own' => $norm($rec['read_own'] ?? []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a full explicit record for a folder.
|
||||||
|
* NOTE: preserves existing 'read_own' so older callers don't wipe it.
|
||||||
|
*/
|
||||||
|
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
|
$existing = $acl['folders'][$folder] ?? ['read_own' => []];
|
||||||
|
$fmt = function (array $arr): array {
|
||||||
|
return array_values(array_unique(array_map('strval', $arr)));
|
||||||
|
};
|
||||||
|
$acl['folders'][$folder] = [
|
||||||
|
'owners' => $fmt($owners),
|
||||||
|
'read' => $fmt($read),
|
||||||
|
'write' => $fmt($write),
|
||||||
|
'share' => $fmt($share),
|
||||||
|
// preserve any own-only grants unless caller explicitly manages them elsewhere
|
||||||
|
'read_own' => isset($existing['read_own']) && is_array($existing['read_own'])
|
||||||
|
? array_values(array_unique(array_map('strval', $existing['read_own'])))
|
||||||
|
: [],
|
||||||
|
];
|
||||||
|
return self::save($acl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic per-user update across many folders.
|
||||||
|
* $grants is like:
|
||||||
|
* [
|
||||||
|
* "folderA" => ["view"=>true, "viewOwn"=>false, "upload"=>true, "manage"=>false, "share"=>false],
|
||||||
|
* "folderB" => ["view"=>false, "viewOwn"=>true, "upload"=>false, "manage"=>false, "share"=>false],
|
||||||
|
* ]
|
||||||
|
* If a folder is INCLUDED with all false, the user is removed from all its buckets.
|
||||||
|
* (If the frontend omits a folder entirely, this method leaves that folder unchanged.)
|
||||||
|
*/
|
||||||
|
public static function applyUserGrantsAtomic(string $user, array $grants): array {
|
||||||
|
$user = (string)$user;
|
||||||
|
$path = self::path();
|
||||||
|
|
||||||
|
$fh = @fopen($path, 'c+');
|
||||||
|
if (!$fh) throw new RuntimeException('Cannot open ACL storage');
|
||||||
|
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read current content
|
||||||
|
$raw = stream_get_contents($fh);
|
||||||
|
if ($raw === false) $raw = '';
|
||||||
|
$acl = json_decode($raw, true);
|
||||||
|
if (!is_array($acl)) $acl = ['folders'=>[], 'groups'=>[]];
|
||||||
|
if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = [];
|
||||||
|
if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = [];
|
||||||
|
|
||||||
|
$changed = [];
|
||||||
|
|
||||||
|
foreach ($grants as $folder => $caps) {
|
||||||
|
$ff = self::normalizeFolder((string)$folder);
|
||||||
|
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) {
|
||||||
|
$acl['folders'][$ff] = ['owners'=>[], 'read'=>[], 'write'=>[], 'share'=>[], 'read_own'=>[]];
|
||||||
|
}
|
||||||
|
$rec =& $acl['folders'][$ff];
|
||||||
|
|
||||||
|
// Remove user from all buckets first (idempotent)
|
||||||
|
foreach (self::BUCKETS as $k) {
|
||||||
|
$rec[$k] = array_values(array_filter(
|
||||||
|
array_map('strval', $rec[$k]),
|
||||||
|
fn($u) => strcasecmp($u, $user) !== 0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$v = !empty($caps['view']); // full view
|
||||||
|
$vo = !empty($caps['viewOwn']); // own-only view
|
||||||
|
$u = !empty($caps['upload']);
|
||||||
|
$m = !empty($caps['manage']);
|
||||||
|
$s = !empty($caps['share']);
|
||||||
|
|
||||||
|
// Implications
|
||||||
|
if ($m) { $v = true; $u = true; } // owner implies read+write
|
||||||
|
if ($u && !$v && !$vo) $vo = true; // upload needs at least own-only visibility
|
||||||
|
if ($s && !$v) $v = true; // sharing implies full read (can be relaxed if desired)
|
||||||
|
|
||||||
|
// Add back per caps
|
||||||
|
if ($m) $rec['owners'][] = $user;
|
||||||
|
if ($v) $rec['read'][] = $user;
|
||||||
|
if ($vo) $rec['read_own'][]= $user;
|
||||||
|
if ($u) $rec['write'][] = $user;
|
||||||
|
if ($s) $rec['share'][] = $user;
|
||||||
|
|
||||||
|
// De-dup
|
||||||
|
foreach (self::BUCKETS as $k) {
|
||||||
|
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed[] = $ff;
|
||||||
|
unset($rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back atomically
|
||||||
|
ftruncate($fh, 0);
|
||||||
|
rewind($fh);
|
||||||
|
$ok = fwrite($fh, json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== false;
|
||||||
|
if (!$ok) throw new RuntimeException('Write failed');
|
||||||
|
|
||||||
|
self::$cache = $acl;
|
||||||
|
return ['ok' => true, 'updated' => $changed];
|
||||||
|
} finally {
|
||||||
|
fflush($fh);
|
||||||
|
flock($fh, LOCK_UN);
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,25 +6,60 @@ require_once PROJECT_ROOT . '/config/config.php';
|
|||||||
class AdminModel
|
class AdminModel
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Parse a shorthand size value (e.g. "5G", "500M", "123K") into bytes.
|
* Parse a shorthand size value (e.g. "5G", "500M", "123K", "50MB", "10KiB") into bytes.
|
||||||
|
* Accepts bare numbers (bytes) and common suffixes: K, KB, KiB, M, MB, MiB, G, GB, GiB, etc.
|
||||||
*
|
*
|
||||||
* @param string $val
|
* @param string $val
|
||||||
* @return int
|
* @return int Bytes (rounded)
|
||||||
*/
|
*/
|
||||||
private static function parseSize(string $val): int
|
private static function parseSize(string $val): int
|
||||||
{
|
{
|
||||||
$unit = strtolower(substr($val, -1));
|
$val = trim($val);
|
||||||
$num = (int) rtrim($val, 'bkmgtpezyBKMGTPESY');
|
if ($val === '') {
|
||||||
switch ($unit) {
|
return 0;
|
||||||
case 'g':
|
|
||||||
return $num * 1024 ** 3;
|
|
||||||
case 'm':
|
|
||||||
return $num * 1024 ** 2;
|
|
||||||
case 'k':
|
|
||||||
return $num * 1024;
|
|
||||||
default:
|
|
||||||
return $num;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match: number + optional unit/suffix (K, KB, KiB, M, MB, MiB, G, GB, GiB, ...)
|
||||||
|
if (preg_match('/^\s*(\d+(?:\.\d+)?)\s*([kmgtpezy]?i?b?)?\s*$/i', $val, $m)) {
|
||||||
|
$num = (float)$m[1];
|
||||||
|
$unit = strtolower($m[2] ?? '');
|
||||||
|
|
||||||
|
switch ($unit) {
|
||||||
|
case 'k': case 'kb': case 'kib':
|
||||||
|
$num *= 1024;
|
||||||
|
break;
|
||||||
|
case 'm': case 'mb': case 'mib':
|
||||||
|
$num *= 1024 ** 2;
|
||||||
|
break;
|
||||||
|
case 'g': case 'gb': case 'gib':
|
||||||
|
$num *= 1024 ** 3;
|
||||||
|
break;
|
||||||
|
case 't': case 'tb': case 'tib':
|
||||||
|
$num *= 1024 ** 4;
|
||||||
|
break;
|
||||||
|
case 'p': case 'pb': case 'pib':
|
||||||
|
$num *= 1024 ** 5;
|
||||||
|
break;
|
||||||
|
case 'e': case 'eb': case 'eib':
|
||||||
|
$num *= 1024 ** 6;
|
||||||
|
break;
|
||||||
|
case 'z': case 'zb': case 'zib':
|
||||||
|
$num *= 1024 ** 7;
|
||||||
|
break;
|
||||||
|
case 'y': case 'yb': case 'yib':
|
||||||
|
$num *= 1024 ** 8;
|
||||||
|
break;
|
||||||
|
// case 'b' or empty => bytes; do nothing
|
||||||
|
default:
|
||||||
|
// If unit is just 'b' or empty, treat as bytes.
|
||||||
|
// For unknown units fall back to bytes.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return (int) round($num);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: cast any unrecognized input to int (bytes)
|
||||||
|
return (int)$val;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,14 +70,24 @@ class AdminModel
|
|||||||
*/
|
*/
|
||||||
public static function updateConfig(array $configUpdate): array
|
public static function updateConfig(array $configUpdate): array
|
||||||
{
|
{
|
||||||
// Validate required OIDC configuration keys.
|
// Ensure encryption key exists
|
||||||
if (
|
if (empty($GLOBALS['encryptionKey']) || !is_string($GLOBALS['encryptionKey'])) {
|
||||||
empty($configUpdate['oidc']['providerUrl']) ||
|
return ["error" => "Server encryption key is not configured."];
|
||||||
empty($configUpdate['oidc']['clientId']) ||
|
}
|
||||||
empty($configUpdate['oidc']['clientSecret']) ||
|
|
||||||
empty($configUpdate['oidc']['redirectUri'])
|
// Only enforce OIDC fields when OIDC is enabled
|
||||||
) {
|
$oidcDisabled = isset($configUpdate['loginOptions']['disableOIDCLogin'])
|
||||||
return ["error" => "Incomplete OIDC configuration."];
|
? (bool)$configUpdate['loginOptions']['disableOIDCLogin']
|
||||||
|
: true; // default to disabled when not present
|
||||||
|
|
||||||
|
if (!$oidcDisabled) {
|
||||||
|
$oidc = $configUpdate['oidc'] ?? [];
|
||||||
|
$required = ['providerUrl','clientId','clientSecret','redirectUri'];
|
||||||
|
foreach ($required as $k) {
|
||||||
|
if (empty($oidc[$k]) || !is_string($oidc[$k])) {
|
||||||
|
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure enableWebDAV flag is boolean (default to false if missing)
|
// Ensure enableWebDAV flag is boolean (default to false if missing)
|
||||||
@@ -67,7 +112,7 @@ class AdminModel
|
|||||||
$configUpdate['sharedMaxUploadSize'] = $sms;
|
$configUpdate['sharedMaxUploadSize'] = $sms;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── NEW: normalize authBypass & authHeaderName ─────────────────────────
|
// Normalize authBypass & authHeaderName
|
||||||
if (!isset($configUpdate['loginOptions']['authBypass'])) {
|
if (!isset($configUpdate['loginOptions']['authBypass'])) {
|
||||||
$configUpdate['loginOptions']['authBypass'] = false;
|
$configUpdate['loginOptions']['authBypass'] = false;
|
||||||
}
|
}
|
||||||
@@ -80,10 +125,8 @@ class AdminModel
|
|||||||
) {
|
) {
|
||||||
$configUpdate['loginOptions']['authHeaderName'] = 'X-Remote-User';
|
$configUpdate['loginOptions']['authHeaderName'] = 'X-Remote-User';
|
||||||
} else {
|
} else {
|
||||||
$configUpdate['loginOptions']['authHeaderName'] =
|
$configUpdate['loginOptions']['authHeaderName'] = trim($configUpdate['loginOptions']['authHeaderName']);
|
||||||
trim($configUpdate['loginOptions']['authHeaderName']);
|
|
||||||
}
|
}
|
||||||
// ───────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Convert configuration to JSON.
|
// Convert configuration to JSON.
|
||||||
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
|
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
|
||||||
@@ -104,13 +147,15 @@ class AdminModel
|
|||||||
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
|
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
|
||||||
// Attempt a cleanup: delete the old file and try again.
|
// Attempt a cleanup: delete the old file and try again.
|
||||||
if (file_exists($configFile)) {
|
if (file_exists($configFile)) {
|
||||||
unlink($configFile);
|
@unlink($configFile);
|
||||||
}
|
}
|
||||||
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
|
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
|
||||||
error_log("AdminModel::updateConfig: Failed to write configuration even after deletion.");
|
error_log("AdminModel::updateConfig: Failed to write configuration even after deletion.");
|
||||||
return ["error" => "Failed to update configuration even after cleanup."];
|
return ["error" => "Failed to update configuration even after cleanup."];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Best-effort normalize perms for host visibility (user rw, group rw)
|
||||||
|
@chmod($configFile, 0664);
|
||||||
|
|
||||||
return ["success" => "Configuration updated successfully."];
|
return ["success" => "Configuration updated successfully."];
|
||||||
}
|
}
|
||||||
@@ -123,13 +168,15 @@ class AdminModel
|
|||||||
public static function getConfig(): array
|
public static function getConfig(): array
|
||||||
{
|
{
|
||||||
$configFile = USERS_DIR . 'adminConfig.json';
|
$configFile = USERS_DIR . 'adminConfig.json';
|
||||||
|
|
||||||
if (file_exists($configFile)) {
|
if (file_exists($configFile)) {
|
||||||
$encryptedContent = file_get_contents($configFile);
|
$encryptedContent = file_get_contents($configFile);
|
||||||
$decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']);
|
$decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']);
|
||||||
if ($decryptedContent === false) {
|
if ($decryptedContent === false) {
|
||||||
http_response_code(500);
|
// Do not set HTTP status here; let the controller decide.
|
||||||
return ["error" => "Failed to decrypt configuration."];
|
return ["error" => "Failed to decrypt configuration."];
|
||||||
}
|
}
|
||||||
|
|
||||||
$config = json_decode($decryptedContent, true);
|
$config = json_decode($decryptedContent, true);
|
||||||
if (!is_array($config)) {
|
if (!is_array($config)) {
|
||||||
$config = [];
|
$config = [];
|
||||||
@@ -137,19 +184,38 @@ class AdminModel
|
|||||||
|
|
||||||
// Normalize login options if missing
|
// Normalize login options if missing
|
||||||
if (!isset($config['loginOptions'])) {
|
if (!isset($config['loginOptions'])) {
|
||||||
|
// Migrate legacy top-level flags; default OIDC to true (disabled)
|
||||||
$config['loginOptions'] = [
|
$config['loginOptions'] = [
|
||||||
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
|
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
|
||||||
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
|
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
|
||||||
'disableOIDCLogin' => isset($config['disableOIDCLogin']) ? (bool)$config['disableOIDCLogin'] : false,
|
'disableOIDCLogin' => isset($config['disableOIDCLogin']) ? (bool)$config['disableOIDCLogin'] : true,
|
||||||
];
|
];
|
||||||
unset($config['disableFormLogin'], $config['disableBasicAuth'], $config['disableOIDCLogin']);
|
unset($config['disableFormLogin'], $config['disableBasicAuth'], $config['disableOIDCLogin']);
|
||||||
} else {
|
} else {
|
||||||
// Ensure proper boolean types
|
// Normalize booleans; default OIDC to true (disabled) if missing
|
||||||
$config['loginOptions']['disableFormLogin'] = (bool)$config['loginOptions']['disableFormLogin'];
|
$lo = &$config['loginOptions'];
|
||||||
$config['loginOptions']['disableBasicAuth'] = (bool)$config['loginOptions']['disableBasicAuth'];
|
$lo['disableFormLogin'] = isset($lo['disableFormLogin']) ? (bool)$lo['disableFormLogin'] : false;
|
||||||
$config['loginOptions']['disableOIDCLogin'] = (bool)$config['loginOptions']['disableOIDCLogin'];
|
$lo['disableBasicAuth'] = isset($lo['disableBasicAuth']) ? (bool)$lo['disableBasicAuth'] : false;
|
||||||
|
$lo['disableOIDCLogin'] = isset($lo['disableOIDCLogin']) ? (bool)$lo['disableOIDCLogin'] : true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure OIDC structure exists
|
||||||
|
if (!isset($config['oidc']) || !is_array($config['oidc'])) {
|
||||||
|
$config['oidc'] = [
|
||||||
|
'providerUrl' => '',
|
||||||
|
'clientId' => '',
|
||||||
|
'clientSecret' => '',
|
||||||
|
'redirectUri' => '',
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $k) {
|
||||||
|
if (!isset($config['oidc'][$k]) || !is_string($config['oidc'][$k])) {
|
||||||
|
$config['oidc'][$k] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize authBypass & authHeaderName
|
||||||
if (!array_key_exists('authBypass', $config['loginOptions'])) {
|
if (!array_key_exists('authBypass', $config['loginOptions'])) {
|
||||||
$config['loginOptions']['authBypass'] = false;
|
$config['loginOptions']['authBypass'] = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -167,38 +233,41 @@ class AdminModel
|
|||||||
if (!isset($config['globalOtpauthUrl'])) {
|
if (!isset($config['globalOtpauthUrl'])) {
|
||||||
$config['globalOtpauthUrl'] = "";
|
$config['globalOtpauthUrl'] = "";
|
||||||
}
|
}
|
||||||
if (!isset($config['header_title']) || empty($config['header_title'])) {
|
if (!isset($config['header_title']) || $config['header_title'] === '') {
|
||||||
$config['header_title'] = "FileRise";
|
$config['header_title'] = "FileRise";
|
||||||
}
|
}
|
||||||
if (!isset($config['enableWebDAV'])) {
|
if (!isset($config['enableWebDAV'])) {
|
||||||
$config['enableWebDAV'] = false;
|
$config['enableWebDAV'] = false;
|
||||||
}
|
}
|
||||||
// Default sharedMaxUploadSize to 50MB or TOTAL_UPLOAD_SIZE if smaller
|
|
||||||
if (!isset($config['sharedMaxUploadSize'])) {
|
// sharedMaxUploadSize: default if missing; clamp if present
|
||||||
$defaultSms = min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE));
|
$maxBytes = self::parseSize(TOTAL_UPLOAD_SIZE);
|
||||||
$config['sharedMaxUploadSize'] = $defaultSms;
|
if (!isset($config['sharedMaxUploadSize']) || !is_numeric($config['sharedMaxUploadSize']) || $config['sharedMaxUploadSize'] < 1) {
|
||||||
|
$config['sharedMaxUploadSize'] = min(50 * 1024 * 1024, $maxBytes);
|
||||||
|
} else {
|
||||||
|
$config['sharedMaxUploadSize'] = (int)min((int)$config['sharedMaxUploadSize'], $maxBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $config;
|
return $config;
|
||||||
} else {
|
|
||||||
// Return defaults.
|
|
||||||
return [
|
|
||||||
'header_title' => "FileRise",
|
|
||||||
'oidc' => [
|
|
||||||
'providerUrl' => 'https://your-oidc-provider.com',
|
|
||||||
'clientId' => 'YOUR_CLIENT_ID',
|
|
||||||
'clientSecret' => 'YOUR_CLIENT_SECRET',
|
|
||||||
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
|
|
||||||
],
|
|
||||||
'loginOptions' => [
|
|
||||||
'disableFormLogin' => false,
|
|
||||||
'disableBasicAuth' => false,
|
|
||||||
'disableOIDCLogin' => false
|
|
||||||
],
|
|
||||||
'globalOtpauthUrl' => "",
|
|
||||||
'enableWebDAV' => false,
|
|
||||||
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE))
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No config on disk; return defaults.
|
||||||
|
return [
|
||||||
|
'header_title' => "FileRise",
|
||||||
|
'oidc' => [
|
||||||
|
'providerUrl' => 'https://your-oidc-provider.com',
|
||||||
|
'clientId' => '',
|
||||||
|
'clientSecret' => '',
|
||||||
|
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
|
||||||
|
],
|
||||||
|
'loginOptions' => [
|
||||||
|
'disableFormLogin' => false,
|
||||||
|
'disableBasicAuth' => false,
|
||||||
|
'disableOIDCLogin' => true
|
||||||
|
],
|
||||||
|
'globalOtpauthUrl' => "",
|
||||||
|
'enableWebDAV' => false,
|
||||||
|
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE))
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,63 +2,154 @@
|
|||||||
// src/models/FolderModel.php
|
// src/models/FolderModel.php
|
||||||
|
|
||||||
require_once PROJECT_ROOT . '/config/config.php';
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
|
|
||||||
class FolderModel
|
class FolderModel
|
||||||
{
|
{
|
||||||
/**
|
/* ============================================================
|
||||||
* Creates a folder under the specified parent (or in root) and creates an empty metadata file.
|
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
|
||||||
*
|
* ============================================================ */
|
||||||
* @param string $folderName The name of the folder to create.
|
|
||||||
* @param string $parent (Optional) The parent folder name. Defaults to empty.
|
/** Load the folder → owner map. */
|
||||||
* @return array Returns an array with a "success" key if the folder was created,
|
public static function getFolderOwners(): array
|
||||||
* or an "error" key if an error occurred.
|
|
||||||
*/
|
|
||||||
public static function createFolder(string $folderName, string $parent = ""): array
|
|
||||||
{
|
{
|
||||||
$folderName = trim($folderName);
|
$f = FOLDER_OWNERS_FILE;
|
||||||
$parent = trim($parent);
|
if (!file_exists($f)) return [];
|
||||||
|
$json = json_decode(@file_get_contents($f), true);
|
||||||
|
return is_array($json) ? $json : [];
|
||||||
|
}
|
||||||
|
|
||||||
// Validate folder name (only letters, numbers, underscores, dashes, and spaces allowed).
|
/** Persist the folder → owner map. */
|
||||||
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
public static function saveFolderOwners(array $map): bool
|
||||||
return ["error" => "Invalid folder name."];
|
{
|
||||||
}
|
return (bool) @file_put_contents(FOLDER_OWNERS_FILE, json_encode($map, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
if ($parent !== "" && !preg_match(REGEX_FOLDER_NAME, $parent)) {
|
}
|
||||||
return ["error" => "Invalid parent folder name."];
|
|
||||||
}
|
|
||||||
|
|
||||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
/** Set (or replace) the owner for a specific folder (relative path or 'root'). */
|
||||||
if ($parent !== "" && strtolower($parent) !== "root") {
|
public static function setOwnerFor(string $folder, string $owner): void
|
||||||
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName;
|
{
|
||||||
$relativePath = $parent . "/" . $folderName;
|
$key = trim($folder, "/\\ ");
|
||||||
} else {
|
$key = ($key === '' ? 'root' : $key);
|
||||||
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $folderName;
|
$owners = self::getFolderOwners();
|
||||||
$relativePath = $folderName;
|
$owners[$key] = $owner;
|
||||||
}
|
self::saveFolderOwners($owners);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the folder already exists.
|
/** Get the owner for a folder (relative path or 'root'); returns null if unmapped. */
|
||||||
if (file_exists($fullPath)) {
|
public static function getOwnerFor(string $folder): ?string
|
||||||
return ["error" => "Folder already exists."];
|
{
|
||||||
}
|
$key = trim($folder, "/\\ ");
|
||||||
|
$key = ($key === '' ? 'root' : $key);
|
||||||
|
$owners = self::getFolderOwners();
|
||||||
|
return $owners[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// Attempt to create the folder.
|
/** Rename a single ownership key (old → new). */
|
||||||
if (mkdir($fullPath, 0755, true)) {
|
public static function renameOwnerKey(string $old, string $new): void
|
||||||
// Create an empty metadata file for the new folder.
|
{
|
||||||
$metadataFile = self::getMetadataFilePath($relativePath);
|
$old = trim($old, "/\\ ");
|
||||||
if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT)) === false) {
|
$new = trim($new, "/\\ ");
|
||||||
return ["error" => "Folder created but failed to create metadata file."];
|
$owners = self::getFolderOwners();
|
||||||
}
|
if (isset($owners[$old])) {
|
||||||
return ["success" => true];
|
$owners[$new] = $owners[$old];
|
||||||
} else {
|
unset($owners[$old]);
|
||||||
return ["error" => "Failed to create folder."];
|
self::saveFolderOwners($owners);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove ownership for a folder and all its descendants. */
|
||||||
|
public static function removeOwnerForTree(string $folder): void
|
||||||
|
{
|
||||||
|
$folder = trim($folder, "/\\ ");
|
||||||
|
$owners = self::getFolderOwners();
|
||||||
|
foreach (array_keys($owners) as $k) {
|
||||||
|
if ($k === $folder || strpos($k, $folder . '/') === 0) {
|
||||||
|
unset($owners[$k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::saveFolderOwners($owners);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename ownership keys for an entire subtree: old/... → new/... */
|
||||||
|
public static function renameOwnersForTree(string $oldFolder, string $newFolder): void
|
||||||
|
{
|
||||||
|
$old = trim($oldFolder, "/\\ ");
|
||||||
|
$new = trim($newFolder, "/\\ ");
|
||||||
|
$owners = self::getFolderOwners();
|
||||||
|
|
||||||
|
$rebased = [];
|
||||||
|
foreach ($owners as $k => $v) {
|
||||||
|
if ($k === $old || strpos($k, $old . '/') === 0) {
|
||||||
|
$suffix = substr($k, strlen($old));
|
||||||
|
// ensure no leading slash duplication
|
||||||
|
$suffix = ltrim($suffix, '/');
|
||||||
|
$rebased[$new . ($suffix !== '' ? '/' . $suffix : '')] = $v;
|
||||||
|
} else {
|
||||||
|
$rebased[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::saveFolderOwners($rebased);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Existing helpers
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the metadata file path for a given folder.
|
* Resolve a (possibly nested) relative folder like "invoices/2025" to a real path
|
||||||
|
* under UPLOAD_DIR. Validates each path segment against REGEX_FOLDER_NAME, enforces
|
||||||
|
* containment, and (optionally) creates the folder.
|
||||||
*
|
*
|
||||||
* @param string $folder The relative folder path.
|
* @param string $folder Relative folder or "root"
|
||||||
* @return string The metadata file path.
|
* @param bool $create Create the folder if missing
|
||||||
|
* @return array [string|null $realPath, string $relative, string|null $error]
|
||||||
*/
|
*/
|
||||||
|
private static function resolveFolderPath(string $folder, bool $create = false): array
|
||||||
|
{
|
||||||
|
$folder = trim($folder) ?: 'root';
|
||||||
|
$relative = 'root';
|
||||||
|
|
||||||
|
$base = realpath(UPLOAD_DIR);
|
||||||
|
if ($base === false) {
|
||||||
|
return [null, 'root', "Uploads directory not configured correctly."];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strtolower($folder) === 'root') {
|
||||||
|
$dir = $base;
|
||||||
|
} else {
|
||||||
|
// validate each segment against REGEX_FOLDER_NAME
|
||||||
|
$parts = array_filter(explode('/', trim($folder, "/\\ ")), fn($p) => $p !== '');
|
||||||
|
if (empty($parts)) {
|
||||||
|
return [null, 'root', "Invalid folder name."];
|
||||||
|
}
|
||||||
|
foreach ($parts as $seg) {
|
||||||
|
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||||
|
return [null, 'root', "Invalid folder name."];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$relative = implode('/', $parts);
|
||||||
|
$dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
if ($create) {
|
||||||
|
if (!mkdir($dir, 0775, true)) {
|
||||||
|
return [null, $relative, "Failed to create folder."];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return [null, $relative, "Folder does not exist."];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$real = realpath($dir);
|
||||||
|
if ($real === false || strpos($real, $base) !== 0) {
|
||||||
|
return [null, $relative, "Invalid folder path."];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$real, $relative, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build metadata file path for a given (relative) folder. */
|
||||||
private static function getMetadataFilePath(string $folder): string
|
private static function getMetadataFilePath(string $folder): string
|
||||||
{
|
{
|
||||||
if (strtolower($folder) === 'root' || trim($folder) === '') {
|
if (strtolower($folder) === 'root' || trim($folder) === '') {
|
||||||
@@ -67,134 +158,175 @@ class FolderModel
|
|||||||
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
|
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a folder under the specified parent (or in root) and creates an empty metadata file.
|
||||||
|
* Also records the creator as the owner (if a session user is available).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a folder on disk and register it in ACL with the creator as owner.
|
||||||
|
* @param string $folderName leaf name
|
||||||
|
* @param string $parent 'root' or nested key (e.g. 'team/reports')
|
||||||
|
* @param string $creator username to set as initial owner (falls back to 'admin')
|
||||||
|
*/
|
||||||
|
public static function createFolder(string $folderName, string $parent = 'root', string $creator = 'admin'): array
|
||||||
|
{
|
||||||
|
$folderName = trim($folderName);
|
||||||
|
$parent = trim($parent);
|
||||||
|
|
||||||
|
if ($folderName === '' || !preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||||
|
return ['success' => false, 'error' => 'Invalid folder name', 'code' => 400];
|
||||||
|
}
|
||||||
|
if ($parent !== '' && strcasecmp($parent, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parent)) {
|
||||||
|
return ['success' => false, 'error' => 'Invalid parent folder', 'code' => 400];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute ACL key and filesystem path
|
||||||
|
$aclKey = ($parent === '' || strcasecmp($parent, 'root') === 0) ? $folderName : ($parent . '/' . $folderName);
|
||||||
|
|
||||||
|
$base = rtrim(UPLOAD_DIR, '/\\');
|
||||||
|
$path = ($parent === '' || strcasecmp($parent, 'root') === 0)
|
||||||
|
? $base . DIRECTORY_SEPARATOR . $folderName
|
||||||
|
: $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $parent) . DIRECTORY_SEPARATOR . $folderName;
|
||||||
|
|
||||||
|
// Safety: stay inside UPLOAD_DIR
|
||||||
|
$realBase = realpath($base);
|
||||||
|
$realPath = $path; // may not exist yet
|
||||||
|
$parentDir = dirname($path);
|
||||||
|
if (!is_dir($parentDir) && !@mkdir($parentDir, 0775, true)) {
|
||||||
|
return ['success' => false, 'error' => 'Failed to create parent path', 'code' => 500];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_dir($path)) {
|
||||||
|
// Idempotent: still ensure ACL record exists
|
||||||
|
ACL::ensureFolderRecord($aclKey, $creator ?: 'admin');
|
||||||
|
return ['success' => true, 'folder' => $aclKey, 'alreadyExists' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!@mkdir($path, 0775, true)) {
|
||||||
|
return ['success' => false, 'error' => 'Failed to create folder', 'code' => 500];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed ACL: owner/read/write/share -> creator; read_own empty
|
||||||
|
ACL::ensureFolderRecord($aclKey, $creator ?: 'admin');
|
||||||
|
|
||||||
|
return ['success' => true, 'folder' => $aclKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a folder if it is empty and removes its corresponding metadata.
|
* Deletes a folder if it is empty and removes its corresponding metadata.
|
||||||
*
|
* Also removes ownership mappings for this folder and all its descendants.
|
||||||
* @param string $folder The folder name (relative to the upload directory).
|
|
||||||
* @return array An associative array with "success" on success or "error" on failure.
|
|
||||||
*/
|
*/
|
||||||
public static function deleteFolder(string $folder): array
|
public static function deleteFolder(string $folder): array
|
||||||
{
|
{
|
||||||
// Prevent deletion of "root".
|
|
||||||
if (strtolower($folder) === 'root') {
|
if (strtolower($folder) === 'root') {
|
||||||
return ["error" => "Cannot delete root folder."];
|
return ["error" => "Cannot delete root folder."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate folder name.
|
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
|
||||||
if (!preg_match(REGEX_FOLDER_NAME, $folder)) {
|
if ($err) return ["error" => $err];
|
||||||
return ["error" => "Invalid folder name."];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the full folder path.
|
// Prevent deletion if not empty.
|
||||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
$items = array_diff(@scandir($real) ?: [], array('.', '..'));
|
||||||
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
|
|
||||||
|
|
||||||
// Check if the folder exists and is a directory.
|
|
||||||
if (!file_exists($folderPath) || !is_dir($folderPath)) {
|
|
||||||
return ["error" => "Folder does not exist."];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent deletion if the folder is not empty.
|
|
||||||
$items = array_diff(scandir($folderPath), array('.', '..'));
|
|
||||||
if (count($items) > 0) {
|
if (count($items) > 0) {
|
||||||
return ["error" => "Folder is not empty."];
|
return ["error" => "Folder is not empty."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to delete the folder.
|
if (!@rmdir($real)) {
|
||||||
if (rmdir($folderPath)) {
|
|
||||||
// Remove corresponding metadata file.
|
|
||||||
$metadataFile = self::getMetadataFilePath($folder);
|
|
||||||
if (file_exists($metadataFile)) {
|
|
||||||
unlink($metadataFile);
|
|
||||||
}
|
|
||||||
return ["success" => true];
|
|
||||||
} else {
|
|
||||||
return ["error" => "Failed to delete folder."];
|
return ["error" => "Failed to delete folder."];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove metadata file (best-effort).
|
||||||
|
$metadataFile = self::getMetadataFilePath($relative);
|
||||||
|
if (file_exists($metadataFile)) {
|
||||||
|
@unlink($metadataFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove ownership mappings for the subtree.
|
||||||
|
self::removeOwnerForTree($relative);
|
||||||
|
|
||||||
|
return ["success" => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renames a folder and updates related metadata files.
|
* Renames a folder and updates related metadata files (by renaming their filenames).
|
||||||
*
|
* Also rewrites ownership keys for the whole subtree from old → new.
|
||||||
* @param string $oldFolder The current folder name (relative to UPLOAD_DIR).
|
|
||||||
* @param string $newFolder The new folder name.
|
|
||||||
* @return array Returns an associative array with "success" on success or "error" on failure.
|
|
||||||
*/
|
*/
|
||||||
public static function renameFolder(string $oldFolder, string $newFolder): array
|
public static function renameFolder(string $oldFolder, string $newFolder): array
|
||||||
{
|
{
|
||||||
// Sanitize and trim folder names.
|
|
||||||
$oldFolder = trim($oldFolder, "/\\ ");
|
$oldFolder = trim($oldFolder, "/\\ ");
|
||||||
$newFolder = trim($newFolder, "/\\ ");
|
$newFolder = trim($newFolder, "/\\ ");
|
||||||
|
|
||||||
// Validate folder names.
|
// Validate names (per-segment)
|
||||||
if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) {
|
foreach ([$oldFolder, $newFolder] as $f) {
|
||||||
return ["error" => "Invalid folder name(s)."];
|
$parts = array_filter(explode('/', $f), fn($p)=>$p!=='');
|
||||||
|
if (empty($parts)) return ["error" => "Invalid folder name(s)."];
|
||||||
|
foreach ($parts as $seg) {
|
||||||
|
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||||
|
return ["error" => "Invalid folder name(s)."];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the full folder paths.
|
[$oldReal, $oldRel, $err] = self::resolveFolderPath($oldFolder, false);
|
||||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
if ($err) return ["error" => $err];
|
||||||
$oldPath = $baseDir . DIRECTORY_SEPARATOR . $oldFolder;
|
|
||||||
$newPath = $baseDir . DIRECTORY_SEPARATOR . $newFolder;
|
|
||||||
|
|
||||||
// Validate that the old folder exists and new folder does not.
|
$base = realpath(UPLOAD_DIR);
|
||||||
if ((realpath($oldPath) === false) || (realpath(dirname($newPath)) === false) ||
|
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
|
||||||
strpos(realpath($oldPath), realpath($baseDir)) !== 0 ||
|
|
||||||
strpos(realpath(dirname($newPath)), realpath($baseDir)) !== 0
|
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p!=='');
|
||||||
) {
|
$newRel = implode('/', $newParts);
|
||||||
|
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
|
||||||
|
|
||||||
|
// Parent of new path must exist
|
||||||
|
$newParent = dirname($newPath);
|
||||||
|
if (!is_dir($newParent) || strpos(realpath($newParent), $base) !== 0) {
|
||||||
return ["error" => "Invalid folder path."];
|
return ["error" => "Invalid folder path."];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file_exists($oldPath) || !is_dir($oldPath)) {
|
|
||||||
return ["error" => "Folder to rename does not exist."];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file_exists($newPath)) {
|
if (file_exists($newPath)) {
|
||||||
return ["error" => "New folder name already exists."];
|
return ["error" => "New folder name already exists."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to rename the folder.
|
if (!@rename($oldReal, $newPath)) {
|
||||||
if (rename($oldPath, $newPath)) {
|
|
||||||
// Update metadata: Rename all metadata files that have the old folder prefix.
|
|
||||||
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldFolder);
|
|
||||||
$newPrefix = str_replace(['/', '\\', ' '], '-', $newFolder);
|
|
||||||
$metadataFiles = glob(META_DIR . $oldPrefix . '*_metadata.json');
|
|
||||||
foreach ($metadataFiles as $oldMetaFile) {
|
|
||||||
$baseName = basename($oldMetaFile);
|
|
||||||
$newBaseName = preg_replace('/^' . preg_quote($oldPrefix, '/') . '/', $newPrefix, $baseName);
|
|
||||||
$newMetaFile = META_DIR . $newBaseName;
|
|
||||||
rename($oldMetaFile, $newMetaFile);
|
|
||||||
}
|
|
||||||
return ["success" => true];
|
|
||||||
} else {
|
|
||||||
return ["error" => "Failed to rename folder."];
|
return ["error" => "Failed to rename folder."];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update metadata filenames (prefix-rename)
|
||||||
|
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldRel);
|
||||||
|
$newPrefix = str_replace(['/', '\\', ' '], '-', $newRel);
|
||||||
|
$globPat = META_DIR . $oldPrefix . '*_metadata.json';
|
||||||
|
$metadataFiles = glob($globPat) ?: [];
|
||||||
|
|
||||||
|
foreach ($metadataFiles as $oldMetaFile) {
|
||||||
|
$baseName = basename($oldMetaFile);
|
||||||
|
$newBase = preg_replace('/^' . preg_quote($oldPrefix, '/') . '/', $newPrefix, $baseName);
|
||||||
|
$newMeta = META_DIR . $newBase;
|
||||||
|
@rename($oldMetaFile, $newMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ownership mapping for the entire subtree.
|
||||||
|
self::renameOwnersForTree($oldRel, $newRel);
|
||||||
|
|
||||||
|
return ["success" => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively scans a directory for subfolders.
|
* Recursively scans a directory for subfolders (relative paths).
|
||||||
*
|
|
||||||
* @param string $dir The full path to the directory.
|
|
||||||
* @param string $relative The relative path from the base directory.
|
|
||||||
* @return array An array of folder paths (relative to the base).
|
|
||||||
*/
|
*/
|
||||||
private static function getSubfolders(string $dir, string $relative = ''): array
|
private static function getSubfolders(string $dir, string $relative = ''): array
|
||||||
{
|
{
|
||||||
$folders = [];
|
$folders = [];
|
||||||
$items = scandir($dir);
|
$items = @scandir($dir) ?: [];
|
||||||
$safeFolderNamePattern = REGEX_FOLDER_NAME;
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
if ($item === '.' || $item === '..') {
|
if ($item === '.' || $item === '..') continue;
|
||||||
continue;
|
if (!preg_match(REGEX_FOLDER_NAME, $item)) continue;
|
||||||
}
|
|
||||||
if (!preg_match($safeFolderNamePattern, $item)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||||
if (is_dir($path)) {
|
if (is_dir($path)) {
|
||||||
$folderPath = ($relative ? $relative . '/' : '') . $item;
|
$folderPath = ($relative ? $relative . '/' : '') . $item;
|
||||||
$folders[] = $folderPath;
|
$folders[] = $folderPath;
|
||||||
$subFolders = self::getSubfolders($path, $folderPath);
|
$folders = array_merge($folders, self::getSubfolders($path, $folderPath));
|
||||||
$folders = array_merge($folders, $subFolders);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $folders;
|
return $folders;
|
||||||
@@ -202,35 +334,32 @@ class FolderModel
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the list of folders (including "root") along with file count metadata.
|
* Retrieves the list of folders (including "root") along with file count metadata.
|
||||||
*
|
* (Ownership filtering is handled in the controller; this function remains unchanged.)
|
||||||
* @return array An array of folder information arrays.
|
|
||||||
*/
|
*/
|
||||||
public static function getFolderList(): array
|
public static function getFolderList($ignoredParent = null, ?string $username = null, array $perms = []): array
|
||||||
{
|
{
|
||||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
$baseDir = realpath(UPLOAD_DIR);
|
||||||
|
if ($baseDir === false) {
|
||||||
|
return []; // or ["error" => "..."]
|
||||||
|
}
|
||||||
|
|
||||||
$folderInfoList = [];
|
$folderInfoList = [];
|
||||||
|
|
||||||
// Process the "root" folder.
|
// root
|
||||||
$rootMetaFile = self::getMetadataFilePath('root');
|
$rootMetaFile = self::getMetadataFilePath('root');
|
||||||
$rootFileCount = 0;
|
$rootFileCount = 0;
|
||||||
if (file_exists($rootMetaFile)) {
|
if (file_exists($rootMetaFile)) {
|
||||||
$rootMetadata = json_decode(file_get_contents($rootMetaFile), true);
|
$rootMetadata = json_decode(file_get_contents($rootMetaFile), true);
|
||||||
$rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0;
|
$rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0;
|
||||||
}
|
}
|
||||||
$folderInfoList[] = [
|
$folderInfoList[] = [
|
||||||
"folder" => "root",
|
"folder" => "root",
|
||||||
"fileCount" => $rootFileCount,
|
"fileCount" => $rootFileCount,
|
||||||
"metadataFile" => basename($rootMetaFile)
|
"metadataFile" => basename($rootMetaFile)
|
||||||
];
|
];
|
||||||
|
|
||||||
// Recursively scan for subfolders.
|
// subfolders
|
||||||
if (is_dir($baseDir)) {
|
$subfolders = is_dir($baseDir) ? self::getSubfolders($baseDir) : [];
|
||||||
$subfolders = self::getSubfolders($baseDir);
|
|
||||||
} else {
|
|
||||||
$subfolders = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// For each subfolder, load metadata to get file counts.
|
|
||||||
foreach ($subfolders as $folder) {
|
foreach ($subfolders as $folder) {
|
||||||
$metaFile = self::getMetadataFilePath($folder);
|
$metaFile = self::getMetadataFilePath($folder);
|
||||||
$fileCount = 0;
|
$fileCount = 0;
|
||||||
@@ -239,147 +368,118 @@ class FolderModel
|
|||||||
$fileCount = is_array($metadata) ? count($metadata) : 0;
|
$fileCount = is_array($metadata) ? count($metadata) : 0;
|
||||||
}
|
}
|
||||||
$folderInfoList[] = [
|
$folderInfoList[] = [
|
||||||
"folder" => $folder,
|
"folder" => $folder,
|
||||||
"fileCount" => $fileCount,
|
"fileCount" => $fileCount,
|
||||||
"metadataFile" => basename($metaFile)
|
"metadataFile" => basename($metaFile)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($username !== null) {
|
||||||
|
$folderInfoList = array_values(array_filter(
|
||||||
|
$folderInfoList,
|
||||||
|
fn($row) => ACL::canRead($username, $perms, $row['folder'])
|
||||||
|
));
|
||||||
|
}
|
||||||
return $folderInfoList;
|
return $folderInfoList;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the share folder record for a given token.
|
* Retrieves the share folder record for a given token.
|
||||||
*
|
|
||||||
* @param string $token The share folder token.
|
|
||||||
* @return array|null The share folder record, or null if not found.
|
|
||||||
*/
|
*/
|
||||||
public static function getShareFolderRecord(string $token): ?array
|
public static function getShareFolderRecord(string $token): ?array
|
||||||
{
|
{
|
||||||
$shareFile = META_DIR . "share_folder_links.json";
|
$shareFile = META_DIR . "share_folder_links.json";
|
||||||
if (!file_exists($shareFile)) {
|
if (!file_exists($shareFile)) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||||||
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
return (is_array($shareLinks) && isset($shareLinks[$token])) ? $shareLinks[$token] : null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return $shareLinks[$token];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves shared folder data based on a share token.
|
* Retrieves shared folder data based on a share token.
|
||||||
*
|
|
||||||
* @param string $token The share folder token.
|
|
||||||
* @param string|null $providedPass The provided password (if any).
|
|
||||||
* @param int $page The page number for pagination.
|
|
||||||
* @param int $itemsPerPage The number of files to display per page.
|
|
||||||
* @return array Associative array with keys:
|
|
||||||
* - 'record': the share record,
|
|
||||||
* - 'folder': the shared folder (relative),
|
|
||||||
* - 'realFolderPath': absolute folder path,
|
|
||||||
* - 'files': array of filenames for the current page,
|
|
||||||
* - 'currentPage': current page number,
|
|
||||||
* - 'totalPages': total pages,
|
|
||||||
* or an 'error' key on failure.
|
|
||||||
*/
|
*/
|
||||||
public static function getSharedFolderData(string $token, ?string $providedPass, int $page = 1, int $itemsPerPage = 10): array
|
public static function getSharedFolderData(string $token, ?string $providedPass, int $page = 1, int $itemsPerPage = 10): array
|
||||||
{
|
{
|
||||||
// Load the share folder record.
|
|
||||||
$shareFile = META_DIR . "share_folder_links.json";
|
$shareFile = META_DIR . "share_folder_links.json";
|
||||||
if (!file_exists($shareFile)) {
|
if (!file_exists($shareFile)) return ["error" => "Share link not found."];
|
||||||
return ["error" => "Share link not found."];
|
|
||||||
}
|
|
||||||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||||||
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
||||||
return ["error" => "Share link not found."];
|
return ["error" => "Share link not found."];
|
||||||
}
|
}
|
||||||
$record = $shareLinks[$token];
|
$record = $shareLinks[$token];
|
||||||
// Check expiration.
|
|
||||||
if (time() > $record['expires']) {
|
if (time() > ($record['expires'] ?? 0)) {
|
||||||
return ["error" => "This share link has expired."];
|
return ["error" => "This share link has expired."];
|
||||||
}
|
}
|
||||||
// If password protection is enabled and no password is provided, signal that.
|
|
||||||
if (!empty($record['password']) && empty($providedPass)) {
|
if (!empty($record['password']) && empty($providedPass)) {
|
||||||
return ["needs_password" => true];
|
return ["needs_password" => true];
|
||||||
}
|
}
|
||||||
if (!empty($record['password']) && !password_verify($providedPass, $record['password'])) {
|
if (!empty($record['password']) && !password_verify($providedPass, $record['password'])) {
|
||||||
return ["error" => "Invalid password."];
|
return ["error" => "Invalid password."];
|
||||||
}
|
}
|
||||||
// Determine the shared folder.
|
|
||||||
$folder = trim($record['folder'], "/\\ ");
|
// Resolve shared folder
|
||||||
$baseDir = realpath(UPLOAD_DIR);
|
$folder = trim((string)$record['folder'], "/\\ ");
|
||||||
if ($baseDir === false) {
|
[$realFolderPath, $relative, $err] = self::resolveFolderPath($folder === '' ? 'root' : $folder, false);
|
||||||
return ["error" => "Uploads directory not configured correctly."];
|
if ($err || !is_dir($realFolderPath)) {
|
||||||
}
|
|
||||||
if (!empty($folder) && strtolower($folder) !== 'root') {
|
|
||||||
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
|
|
||||||
} else {
|
|
||||||
$folder = "root";
|
|
||||||
$folderPath = $baseDir;
|
|
||||||
}
|
|
||||||
$realFolderPath = realpath($folderPath);
|
|
||||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
|
||||||
if ($realFolderPath === false || strpos($realFolderPath, $uploadDirReal) !== 0 || !is_dir($realFolderPath)) {
|
|
||||||
return ["error" => "Shared folder not found."];
|
return ["error" => "Shared folder not found."];
|
||||||
}
|
}
|
||||||
// Scan for files (only files).
|
|
||||||
$allFiles = array_values(array_filter(scandir($realFolderPath), function ($item) use ($realFolderPath) {
|
// List files (safe names only; skip hidden)
|
||||||
return is_file($realFolderPath . DIRECTORY_SEPARATOR . $item);
|
$all = @scandir($realFolderPath) ?: [];
|
||||||
}));
|
$allFiles = [];
|
||||||
sort($allFiles);
|
foreach ($all as $it) {
|
||||||
$totalFiles = count($allFiles);
|
if ($it === '.' || $it === '..') continue;
|
||||||
$totalPages = max(1, ceil($totalFiles / $itemsPerPage));
|
if ($it[0] === '.') continue;
|
||||||
$currentPage = min($page, $totalPages);
|
if (!preg_match(REGEX_FILE_NAME, $it)) continue;
|
||||||
$startIndex = ($currentPage - 1) * $itemsPerPage;
|
if (is_file($realFolderPath . DIRECTORY_SEPARATOR . $it)) {
|
||||||
|
$allFiles[] = $it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($allFiles, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
|
||||||
|
$totalFiles = count($allFiles);
|
||||||
|
$totalPages = max(1, (int)ceil($totalFiles / max(1, $itemsPerPage)));
|
||||||
|
$currentPage = min(max(1, $page), $totalPages);
|
||||||
|
$startIndex = ($currentPage - 1) * $itemsPerPage;
|
||||||
$filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage);
|
$filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"record" => $record,
|
"record" => $record,
|
||||||
"folder" => $folder,
|
"folder" => $relative,
|
||||||
"realFolderPath" => $realFolderPath,
|
"realFolderPath"=> $realFolderPath,
|
||||||
"files" => $filesOnPage,
|
"files" => $filesOnPage,
|
||||||
"currentPage" => $currentPage,
|
"currentPage" => $currentPage,
|
||||||
"totalPages" => $totalPages
|
"totalPages" => $totalPages
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a share link for a folder.
|
* Creates a share link for a folder.
|
||||||
*
|
|
||||||
* @param string $folder The folder to share (relative to UPLOAD_DIR).
|
|
||||||
* @param int $expirationSeconds How many seconds until expiry.
|
|
||||||
* @param string $password Optional password.
|
|
||||||
* @param int $allowUpload 0 or 1 whether uploads are allowed.
|
|
||||||
* @return array ["token","expires","link"] on success, or ["error"].
|
|
||||||
*/
|
*/
|
||||||
public static function createShareFolderLink(string $folder, int $expirationSeconds = 3600, string $password = "", int $allowUpload = 0): array
|
public static function createShareFolderLink(string $folder, int $expirationSeconds = 3600, string $password = "", int $allowUpload = 0): array
|
||||||
{
|
{
|
||||||
// Validate folder
|
// Validate folder (and ensure it exists)
|
||||||
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
|
||||||
return ["error" => "Invalid folder name."];
|
if ($err) return ["error" => $err];
|
||||||
}
|
|
||||||
|
|
||||||
// Token
|
// Token
|
||||||
try {
|
try {
|
||||||
$token = bin2hex(random_bytes(16));
|
$token = bin2hex(random_bytes(16));
|
||||||
} catch (Exception $e) {
|
} catch (\Throwable $e) {
|
||||||
return ["error" => "Could not generate token."];
|
return ["error" => "Could not generate token."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expiry
|
$expires = time() + max(1, $expirationSeconds);
|
||||||
$expires = time() + $expirationSeconds;
|
$hashedPassword= $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
|
||||||
|
|
||||||
// Password hash
|
|
||||||
$hashedPassword = $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
|
|
||||||
|
|
||||||
// Load existing
|
|
||||||
$shareFile = META_DIR . "share_folder_links.json";
|
$shareFile = META_DIR . "share_folder_links.json";
|
||||||
$links = file_exists($shareFile)
|
$links = file_exists($shareFile)
|
||||||
? json_decode(file_get_contents($shareFile), true) ?? []
|
? (json_decode(file_get_contents($shareFile), true) ?? [])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Cleanup
|
// cleanup expired
|
||||||
$now = time();
|
$now = time();
|
||||||
foreach ($links as $k => $v) {
|
foreach ($links as $k => $v) {
|
||||||
if (!empty($v['expires']) && $v['expires'] < $now) {
|
if (!empty($v['expires']) && $v['expires'] < $now) {
|
||||||
@@ -387,107 +487,78 @@ class FolderModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new
|
|
||||||
$links[$token] = [
|
$links[$token] = [
|
||||||
"folder" => $folder,
|
"folder" => $relative,
|
||||||
"expires" => $expires,
|
"expires" => $expires,
|
||||||
"password" => $hashedPassword,
|
"password" => $hashedPassword,
|
||||||
"allowUpload" => $allowUpload
|
"allowUpload" => $allowUpload ? 1 : 0
|
||||||
];
|
];
|
||||||
|
|
||||||
// Save
|
if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||||
if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)) === false) {
|
|
||||||
return ["error" => "Could not save share link."];
|
return ["error" => "Could not save share link."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build URL
|
// Build URL
|
||||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
|
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||||
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
|
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||||
$baseUrl = $protocol . '://' . rtrim($host, '/');
|
$scheme = $https ? 'https' : 'http';
|
||||||
$link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token);
|
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
|
||||||
|
$baseUrl = $scheme . '://' . rtrim($host, '/');
|
||||||
|
$link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token);
|
||||||
|
|
||||||
return ["token" => $token, "expires" => $expires, "link" => $link];
|
return ["token" => $token, "expires" => $expires, "link" => $link];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves information for a shared file from a shared folder link.
|
* Retrieves information for a shared file from a shared folder link.
|
||||||
*
|
|
||||||
* @param string $token The share folder token.
|
|
||||||
* @param string $file The requested file name.
|
|
||||||
* @return array An associative array with keys:
|
|
||||||
* - "error": error message, if any,
|
|
||||||
* - "realFilePath": the absolute path to the file,
|
|
||||||
* - "mimeType": the detected MIME type.
|
|
||||||
*/
|
*/
|
||||||
public static function getSharedFileInfo(string $token, string $file): array
|
public static function getSharedFileInfo(string $token, string $file): array
|
||||||
{
|
{
|
||||||
// Load the share folder record.
|
|
||||||
$shareFile = META_DIR . "share_folder_links.json";
|
$shareFile = META_DIR . "share_folder_links.json";
|
||||||
if (!file_exists($shareFile)) {
|
if (!file_exists($shareFile)) return ["error" => "Share link not found."];
|
||||||
return ["error" => "Share link not found."];
|
|
||||||
}
|
|
||||||
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
$shareLinks = json_decode(file_get_contents($shareFile), true);
|
||||||
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
|
||||||
return ["error" => "Share link not found."];
|
return ["error" => "Share link not found."];
|
||||||
}
|
}
|
||||||
$record = $shareLinks[$token];
|
$record = $shareLinks[$token];
|
||||||
|
|
||||||
// Check if the link has expired.
|
if (time() > ($record['expires'] ?? 0)) {
|
||||||
if (time() > $record['expires']) {
|
|
||||||
return ["error" => "This share link has expired."];
|
return ["error" => "This share link has expired."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the shared folder.
|
[$realFolderPath, , $err] = self::resolveFolderPath((string)$record['folder'], false);
|
||||||
$folder = trim($record['folder'], "/\\ ");
|
if ($err || !is_dir($realFolderPath)) {
|
||||||
$baseDir = realpath(UPLOAD_DIR);
|
|
||||||
if ($baseDir === false) {
|
|
||||||
return ["error" => "Uploads directory not configured correctly."];
|
|
||||||
}
|
|
||||||
if (!empty($folder) && strtolower($folder) !== 'root') {
|
|
||||||
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
|
|
||||||
} else {
|
|
||||||
$folderPath = $baseDir;
|
|
||||||
}
|
|
||||||
$realFolderPath = realpath($folderPath);
|
|
||||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
|
||||||
if ($realFolderPath === false || strpos($realFolderPath, $uploadDirReal) !== 0 || !is_dir($realFolderPath)) {
|
|
||||||
return ["error" => "Shared folder not found."];
|
return ["error" => "Shared folder not found."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize the file name to prevent path traversal.
|
$file = basename(trim($file));
|
||||||
if (strpos($file, "/") !== false || strpos($file, "\\") !== false) {
|
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||||
return ["error" => "Invalid file name."];
|
return ["error" => "Invalid file name."];
|
||||||
}
|
}
|
||||||
$file = basename($file);
|
|
||||||
|
|
||||||
// Build the full file path.
|
$full = $realFolderPath . DIRECTORY_SEPARATOR . $file;
|
||||||
$filePath = $realFolderPath . DIRECTORY_SEPARATOR . $file;
|
$real = realpath($full);
|
||||||
$realFilePath = realpath($filePath);
|
if ($real === false || strpos($real, $realFolderPath) !== 0 || !is_file($real)) {
|
||||||
if ($realFilePath === false || strpos($realFilePath, $realFolderPath) !== 0 || !is_file($realFilePath)) {
|
|
||||||
return ["error" => "File not found."];
|
return ["error" => "File not found."];
|
||||||
}
|
}
|
||||||
|
|
||||||
$mimeType = mime_content_type($realFilePath);
|
$mime = function_exists('mime_content_type') ? mime_content_type($real) : 'application/octet-stream';
|
||||||
return [
|
return ["realFilePath" => $real, "mimeType" => $mime];
|
||||||
"realFilePath" => $realFilePath,
|
|
||||||
"mimeType" => $mimeType
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles uploading a file to a shared folder.
|
* Handles uploading a file to a shared folder.
|
||||||
*
|
|
||||||
* @param string $token The share folder token.
|
|
||||||
* @param array $fileUpload The $_FILES['fileToUpload'] array.
|
|
||||||
* @return array An associative array with "success" on success or "error" on failure.
|
|
||||||
*/
|
*/
|
||||||
public static function uploadToSharedFolder(string $token, array $fileUpload): array
|
public static function uploadToSharedFolder(string $token, array $fileUpload): array
|
||||||
{
|
{
|
||||||
// Define maximum file size and allowed extensions.
|
// Max size & allowed extensions (mirror FileModel’s common types)
|
||||||
$maxSize = 50 * 1024 * 1024; // 50 MB
|
$maxSize = 50 * 1024 * 1024; // 50 MB
|
||||||
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'txt', 'xls', 'xlsx', 'ppt', 'pptx', 'mp4', 'webm', 'mp3', 'mkv'];
|
$allowedExtensions = [
|
||||||
|
'jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx',
|
||||||
|
'mp4','webm','mp3','mkv','csv','json','xml','md'
|
||||||
|
];
|
||||||
|
|
||||||
// Load the share folder record.
|
|
||||||
$shareFile = META_DIR . "share_folder_links.json";
|
$shareFile = META_DIR . "share_folder_links.json";
|
||||||
if (!file_exists($shareFile)) {
|
if (!file_exists($shareFile)) {
|
||||||
return ["error" => "Share record not found."];
|
return ["error" => "Share record not found."];
|
||||||
@@ -498,75 +569,50 @@ class FolderModel
|
|||||||
}
|
}
|
||||||
$record = $shareLinks[$token];
|
$record = $shareLinks[$token];
|
||||||
|
|
||||||
// Check expiration.
|
if (time() > ($record['expires'] ?? 0)) {
|
||||||
if (time() > $record['expires']) {
|
|
||||||
return ["error" => "This share link has expired."];
|
return ["error" => "This share link has expired."];
|
||||||
}
|
}
|
||||||
|
if (empty($record['allowUpload']) || (int)$record['allowUpload'] !== 1) {
|
||||||
// Check whether uploads are allowed.
|
|
||||||
if (empty($record['allowUpload']) || $record['allowUpload'] != 1) {
|
|
||||||
return ["error" => "File uploads are not allowed for this share."];
|
return ["error" => "File uploads are not allowed for this share."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file upload presence.
|
if (($fileUpload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
if ($fileUpload['error'] !== UPLOAD_ERR_OK) {
|
return ["error" => "File upload error. Code: " . (int)$fileUpload['error']];
|
||||||
return ["error" => "File upload error. Code: " . $fileUpload['error']];
|
|
||||||
}
|
}
|
||||||
|
if (($fileUpload['size'] ?? 0) > $maxSize) {
|
||||||
if ($fileUpload['size'] > $maxSize) {
|
|
||||||
return ["error" => "File size exceeds allowed limit."];
|
return ["error" => "File size exceeds allowed limit."];
|
||||||
}
|
}
|
||||||
|
|
||||||
$uploadedName = basename($fileUpload['name']);
|
$uploadedName = basename((string)($fileUpload['name'] ?? ''));
|
||||||
$ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION));
|
$ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION));
|
||||||
if (!in_array($ext, $allowedExtensions)) {
|
if (!in_array($ext, $allowedExtensions, true)) {
|
||||||
return ["error" => "File type not allowed."];
|
return ["error" => "File type not allowed."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the target folder from the share record.
|
// Resolve target folder
|
||||||
$folderName = trim($record['folder'], "/\\");
|
[$targetDir, $relative, $err] = self::resolveFolderPath((string)$record['folder'], true);
|
||||||
$targetFolder = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
if ($err) return ["error" => $err];
|
||||||
if (!empty($folderName) && strtolower($folderName) !== 'root') {
|
|
||||||
$targetFolder .= $folderName;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify target folder exists.
|
// New safe filename
|
||||||
$realTargetFolder = realpath($targetFolder);
|
$safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
|
||||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
$newFilename= uniqid('', true) . "_" . $safeBase;
|
||||||
if ($realTargetFolder === false || strpos($realTargetFolder, $uploadDirReal) !== 0 || !is_dir($realTargetFolder)) {
|
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
|
||||||
return ["error" => "Shared folder not found."];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a new filename (using uniqid and sanitizing the original name).
|
|
||||||
$newFilename = uniqid() . "_" . preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
|
|
||||||
$targetPath = $realTargetFolder . DIRECTORY_SEPARATOR . $newFilename;
|
|
||||||
|
|
||||||
// Move the uploaded file.
|
|
||||||
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
|
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
|
||||||
return ["error" => "Failed to move the uploaded file."];
|
return ["error" => "Failed to move the uploaded file."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Metadata Update ---
|
// Update metadata (uploaded + modified + uploader)
|
||||||
// Determine metadata file.
|
$metadataFile = self::getMetadataFilePath($relative);
|
||||||
$metadataKey = (empty($folderName) || strtolower($folderName) === "root") ? "root" : $folderName;
|
$meta = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
|
||||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
|
||||||
$metadataFile = META_DIR . $metadataFileName;
|
$now = date(DATE_TIME_FORMAT);
|
||||||
$metadataCollection = [];
|
$meta[$newFilename] = [
|
||||||
if (file_exists($metadataFile)) {
|
"uploaded" => $now,
|
||||||
$data = file_get_contents($metadataFile);
|
"modified" => $now,
|
||||||
$metadataCollection = json_decode($data, true);
|
"uploader" => "Outside Share"
|
||||||
if (!is_array($metadataCollection)) {
|
|
||||||
$metadataCollection = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$uploadedDate = date(DATE_TIME_FORMAT);
|
|
||||||
$uploader = "Outside Share"; // As per your original implementation.
|
|
||||||
// Update metadata with the new file's info.
|
|
||||||
$metadataCollection[$newFilename] = [
|
|
||||||
"uploaded" => $uploadedDate,
|
|
||||||
"uploader" => $uploader
|
|
||||||
];
|
];
|
||||||
file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT));
|
file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
|
||||||
return ["success" => "File uploaded successfully.", "newFilename" => $newFilename];
|
return ["success" => "File uploaded successfully.", "newFilename" => $newFilename];
|
||||||
}
|
}
|
||||||
@@ -574,9 +620,7 @@ class FolderModel
|
|||||||
public static function getAllShareFolderLinks(): array
|
public static function getAllShareFolderLinks(): array
|
||||||
{
|
{
|
||||||
$shareFile = META_DIR . "share_folder_links.json";
|
$shareFile = META_DIR . "share_folder_links.json";
|
||||||
if (!file_exists($shareFile)) {
|
if (!file_exists($shareFile)) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
$links = json_decode(file_get_contents($shareFile), true);
|
$links = json_decode(file_get_contents($shareFile), true);
|
||||||
return is_array($links) ? $links : [];
|
return is_array($links) ? $links : [];
|
||||||
}
|
}
|
||||||
@@ -584,15 +628,13 @@ class FolderModel
|
|||||||
public static function deleteShareFolderLink(string $token): bool
|
public static function deleteShareFolderLink(string $token): bool
|
||||||
{
|
{
|
||||||
$shareFile = META_DIR . "share_folder_links.json";
|
$shareFile = META_DIR . "share_folder_links.json";
|
||||||
if (!file_exists($shareFile)) {
|
if (!file_exists($shareFile)) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$links = json_decode(file_get_contents($shareFile), true);
|
$links = json_decode(file_get_contents($shareFile), true);
|
||||||
if (!is_array($links) || !isset($links[$token])) {
|
if (!is_array($links) || !isset($links[$token])) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
unset($links[$token]);
|
unset($links[$token]);
|
||||||
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT));
|
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace FileRise\WebDAV;
|
namespace FileRise\WebDAV;
|
||||||
|
|
||||||
// Bootstrap constants and models
|
require_once __DIR__ . '/../../config/config.php'; // constants + loadUserPermissions()
|
||||||
require_once __DIR__ . '/../../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
|
|
||||||
require_once __DIR__ . '/../../vendor/autoload.php'; // SabreDAV
|
require_once __DIR__ . '/../../vendor/autoload.php'; // SabreDAV
|
||||||
|
require_once __DIR__ . '/../../src/lib/ACL.php';
|
||||||
require_once __DIR__ . '/../../src/models/FolderModel.php';
|
require_once __DIR__ . '/../../src/models/FolderModel.php';
|
||||||
require_once __DIR__ . '/../../src/models/FileModel.php';
|
require_once __DIR__ . '/../../src/models/FileModel.php';
|
||||||
require_once __DIR__ . '/FileRiseFile.php';
|
require_once __DIR__ . '/FileRiseFile.php';
|
||||||
@@ -12,24 +12,27 @@ use Sabre\DAV\ICollection;
|
|||||||
use Sabre\DAV\INode;
|
use Sabre\DAV\INode;
|
||||||
use Sabre\DAV\Exception\NotFound;
|
use Sabre\DAV\Exception\NotFound;
|
||||||
use Sabre\DAV\Exception\Forbidden;
|
use Sabre\DAV\Exception\Forbidden;
|
||||||
use FileRise\WebDAV\FileRiseFile;
|
|
||||||
use FolderModel;
|
|
||||||
use FileModel;
|
|
||||||
|
|
||||||
class FileRiseDirectory implements ICollection, INode {
|
class FileRiseDirectory implements ICollection, INode {
|
||||||
private string $path;
|
private string $path;
|
||||||
private string $user;
|
private string $user;
|
||||||
private bool $folderOnly;
|
private bool $isAdmin;
|
||||||
|
private array $perms;
|
||||||
|
|
||||||
|
/** cache of folder => metadata array */
|
||||||
|
private array $metaCache = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $path Absolute filesystem path (no trailing slash)
|
* @param string $path Absolute filesystem path (no trailing slash)
|
||||||
* @param string $user Authenticated username
|
* @param string $user Authenticated username
|
||||||
* @param bool $folderOnly If true, non‑admins only see $path/{user}
|
* @param bool $isAdmin
|
||||||
|
* @param array $perms user-permissions map (readOnly, disableUpload, bypassOwnership, etc.)
|
||||||
*/
|
*/
|
||||||
public function __construct(string $path, string $user, bool $folderOnly) {
|
public function __construct(string $path, string $user, bool $isAdmin, array $perms) {
|
||||||
$this->path = rtrim($path, '/\\');
|
$this->path = rtrim($path, '/\\');
|
||||||
$this->user = $user;
|
$this->user = $user;
|
||||||
$this->folderOnly = $folderOnly;
|
$this->isAdmin = $isAdmin;
|
||||||
|
$this->perms = $perms;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── INode ───────────────────────────────────────────
|
// ── INode ───────────────────────────────────────────
|
||||||
@@ -39,72 +42,185 @@ class FileRiseDirectory implements ICollection, INode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getLastModified(): int {
|
public function getLastModified(): int {
|
||||||
return filemtime($this->path);
|
return @filemtime($this->path) ?: time();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(): void {
|
public function delete(): void {
|
||||||
throw new Forbidden('Cannot delete this node');
|
throw new Forbidden('Cannot delete directories via WebDAV');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setName($name): void {
|
public function setName($name): void {
|
||||||
throw new Forbidden('Renaming not supported');
|
throw new Forbidden('Renaming directories is not supported');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ICollection ────────────────────────────────────
|
// ── ICollection ────────────────────────────────────
|
||||||
|
|
||||||
public function getChildren(): array {
|
public function getChildren(): array {
|
||||||
|
// Determine “folder key” relative to UPLOAD_DIR for ACL checks
|
||||||
|
$folderKey = $this->folderKeyForPath($this->path);
|
||||||
|
|
||||||
|
// Check view permission on *this* directory
|
||||||
|
$canFull = \ACL::canRead($this->user, $this->perms, $folderKey);
|
||||||
|
$canOwn = \ACL::hasGrant($this->user, $folderKey, 'read_own');
|
||||||
|
if (!$this->isAdmin && !$canFull && !$canOwn) {
|
||||||
|
throw new Forbidden('No view access to this folder');
|
||||||
|
}
|
||||||
|
|
||||||
$nodes = [];
|
$nodes = [];
|
||||||
|
$hide = ['trash','profile_pics']; // internal dirs to hide
|
||||||
foreach (new \DirectoryIterator($this->path) as $item) {
|
foreach (new \DirectoryIterator($this->path) as $item) {
|
||||||
if ($item->isDot()) continue;
|
if ($item->isDot()) continue;
|
||||||
|
$name = $item->getFilename();
|
||||||
|
if (in_array(strtolower($name), $hide, true)) continue;
|
||||||
|
|
||||||
$full = $item->getPathname();
|
$full = $item->getPathname();
|
||||||
|
|
||||||
if ($item->isDir()) {
|
if ($item->isDir()) {
|
||||||
$nodes[] = new self($full, $this->user, $this->folderOnly);
|
// Decide if the *child folder* should be visible
|
||||||
} else {
|
$childKey = $this->folderKeyForPath($full);
|
||||||
$nodes[] = new FileRiseFile($full, $this->user);
|
$canChild = $this->isAdmin
|
||||||
|
|| \ACL::canRead($this->user, $this->perms, $childKey)
|
||||||
|
|| \ACL::hasGrant($this->user, $childKey, 'read_own');
|
||||||
|
|
||||||
|
if ($canChild) {
|
||||||
|
$nodes[] = new self($full, $this->user, $this->isAdmin, $this->perms);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File in this directory: only list if full-view OR (own-only AND owner)
|
||||||
|
if ($canFull || $this->fileIsOwnedByUser($folderKey, $name)) {
|
||||||
|
$nodes[] = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Apply folder‑only at the top level
|
|
||||||
if (
|
|
||||||
$this->folderOnly
|
|
||||||
&& realpath($this->path) === realpath(rtrim(UPLOAD_DIR,'/\\'))
|
|
||||||
) {
|
|
||||||
$nodes = array_filter($nodes, fn(INode $n)=> $n->getName() === $this->user);
|
|
||||||
}
|
|
||||||
return array_values($nodes);
|
return array_values($nodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function childExists($name): bool {
|
public function childExists($name): bool {
|
||||||
return file_exists($this->path . DIRECTORY_SEPARATOR . $name);
|
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||||
|
if (!file_exists($full)) return false;
|
||||||
|
|
||||||
|
$folderKey = $this->folderKeyForPath($this->path);
|
||||||
|
$isDir = is_dir($full);
|
||||||
|
|
||||||
|
if ($isDir) {
|
||||||
|
$childKey = $this->folderKeyForPath($full);
|
||||||
|
return $this->isAdmin
|
||||||
|
|| \ACL::canRead($this->user, $this->perms, $childKey)
|
||||||
|
|| \ACL::hasGrant($this->user, $childKey, 'read_own');
|
||||||
|
}
|
||||||
|
|
||||||
|
// file
|
||||||
|
$canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey);
|
||||||
|
if ($canFull) return true;
|
||||||
|
|
||||||
|
return \ACL::hasGrant($this->user, $folderKey, 'read_own')
|
||||||
|
&& $this->fileIsOwnedByUser($folderKey, $name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getChild($name): INode {
|
public function getChild($name): INode {
|
||||||
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||||
if (!file_exists($full)) throw new NotFound("Not found: $name");
|
if (!file_exists($full)) throw new NotFound("Not found: $name");
|
||||||
return is_dir($full)
|
|
||||||
? new self($full, $this->user, $this->folderOnly)
|
$folderKey = $this->folderKeyForPath($this->path);
|
||||||
: new FileRiseFile($full, $this->user);
|
if (is_dir($full)) {
|
||||||
|
$childKey = $this->folderKeyForPath($full);
|
||||||
|
$canDir = $this->isAdmin
|
||||||
|
|| \ACL::canRead($this->user, $this->perms, $childKey)
|
||||||
|
|| \ACL::hasGrant($this->user, $childKey, 'read_own');
|
||||||
|
if (!$canDir) throw new Forbidden('No view access to requested folder');
|
||||||
|
return new self($full, $this->user, $this->isAdmin, $this->perms);
|
||||||
|
}
|
||||||
|
|
||||||
|
// file
|
||||||
|
$canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey);
|
||||||
|
if (!$canFull) {
|
||||||
|
if (!\ACL::hasGrant($this->user, $folderKey, 'read_own') || !$this->fileIsOwnedByUser($folderKey, $name)) {
|
||||||
|
throw new Forbidden('No view access to requested file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createFile($name, $data = null): INode {
|
public function createFile($name, $data = null): INode {
|
||||||
|
$folderKey = $this->folderKeyForPath($this->path);
|
||||||
|
|
||||||
|
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
|
||||||
|
throw new Forbidden('No write access to this folder');
|
||||||
|
}
|
||||||
|
if (!empty($this->perms['disableUpload']) && !$this->isAdmin) {
|
||||||
|
throw new Forbidden('Uploads are disabled for your account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write directly to FS, then ensure metadata via FileRiseFile::put()
|
||||||
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||||
$content = is_resource($data) ? stream_get_contents($data) : (string)$data;
|
$content = is_resource($data) ? stream_get_contents($data) : (string)$data;
|
||||||
|
|
||||||
// Compute folder‑key relative to UPLOAD_DIR
|
// Let FileRiseFile handle metadata & overwrite semantics
|
||||||
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1);
|
$fileNode = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
|
||||||
$parts = explode('/', str_replace('\\','/',$rel));
|
$fileNode->put($content);
|
||||||
$filename = array_pop($parts);
|
|
||||||
$folder = empty($parts) ? 'root' : implode('/', $parts);
|
|
||||||
|
|
||||||
FileModel::saveFile($folder, $filename, $content, $this->user);
|
return $fileNode;
|
||||||
return new FileRiseFile($full, $this->user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createDirectory($name): INode {
|
public function createDirectory($name): INode {
|
||||||
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
$parentKey = $this->folderKeyForPath($this->path);
|
||||||
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1);
|
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $parentKey)) {
|
||||||
|
throw new Forbidden('No permission to create subfolders here');
|
||||||
|
}
|
||||||
|
|
||||||
|
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||||
|
if (!is_dir($full)) {
|
||||||
|
@mkdir($full, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileRise folder bookkeeping (owner = creator)
|
||||||
|
$rel = $this->relFromUploads($full);
|
||||||
$parent = dirname(str_replace('\\','/',$rel));
|
$parent = dirname(str_replace('\\','/',$rel));
|
||||||
if ($parent === '.' || $parent === '/') $parent = '';
|
if ($parent === '.' || $parent === '/') $parent = '';
|
||||||
FolderModel::createFolder($name, $parent, $this->user);
|
\FolderModel::createFolder($name, $parent, $this->user);
|
||||||
return new self($full, $this->user, $this->folderOnly);
|
|
||||||
|
return new self($full, $this->user, $this->isAdmin, $this->perms);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function folderKeyForPath(string $absPath): string {
|
||||||
|
$base = rtrim(UPLOAD_DIR, '/\\');
|
||||||
|
$realBase = realpath($base) ?: $base;
|
||||||
|
$real = realpath($absPath) ?: $absPath;
|
||||||
|
|
||||||
|
if (stripos($real, $realBase) !== 0) return 'root';
|
||||||
|
$rel = ltrim(str_replace('\\','/', substr($real, strlen($realBase))), '/');
|
||||||
|
return ($rel === '' ? 'root' : $rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function relFromUploads(string $absPath): string {
|
||||||
|
$base = rtrim(UPLOAD_DIR, '/\\');
|
||||||
|
return ltrim(str_replace('\\','/', substr($absPath, strlen($base))), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadMeta(string $folderKey): array {
|
||||||
|
if (isset($this->metaCache[$folderKey])) return $this->metaCache[$folderKey];
|
||||||
|
|
||||||
|
$metaFile = META_DIR . (
|
||||||
|
$folderKey === 'root'
|
||||||
|
? 'root_metadata.json'
|
||||||
|
: str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
if (is_file($metaFile)) {
|
||||||
|
$decoded = json_decode(@file_get_contents($metaFile), true);
|
||||||
|
if (is_array($decoded)) $data = $decoded;
|
||||||
|
}
|
||||||
|
return $this->metaCache[$folderKey] = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fileIsOwnedByUser(string $folderKey, string $fileName): bool {
|
||||||
|
$meta = $this->loadMeta($folderKey);
|
||||||
|
return isset($meta[$fileName]['uploader'])
|
||||||
|
&& strcasecmp((string)$meta[$fileName]['uploader'], $this->user) === 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,18 +5,25 @@ namespace FileRise\WebDAV;
|
|||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||||
|
require_once __DIR__ . '/../../src/lib/ACL.php';
|
||||||
require_once __DIR__ . '/../../src/models/FileModel.php';
|
require_once __DIR__ . '/../../src/models/FileModel.php';
|
||||||
|
require_once __DIR__ . '/CurrentUser.php';
|
||||||
|
|
||||||
use Sabre\DAV\IFile;
|
use Sabre\DAV\IFile;
|
||||||
use Sabre\DAV\INode;
|
use Sabre\DAV\INode;
|
||||||
use Sabre\DAV\Exception\Forbidden;
|
use Sabre\DAV\Exception\Forbidden;
|
||||||
use FileModel;
|
|
||||||
|
|
||||||
class FileRiseFile implements IFile, INode {
|
class FileRiseFile implements IFile, INode {
|
||||||
private string $path;
|
private string $path;
|
||||||
|
private string $user;
|
||||||
|
private bool $isAdmin;
|
||||||
|
private array $perms;
|
||||||
|
|
||||||
public function __construct(string $path) {
|
public function __construct(string $path, string $user, bool $isAdmin, array $perms) {
|
||||||
$this->path = $path;
|
$this->path = $path;
|
||||||
|
$this->user = $user;
|
||||||
|
$this->isAdmin = $isAdmin;
|
||||||
|
$this->perms = $perms;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── INode ───────────────────────────────────────────
|
// ── INode ───────────────────────────────────────────
|
||||||
@@ -26,90 +33,134 @@ class FileRiseFile implements IFile, INode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getLastModified(): int {
|
public function getLastModified(): int {
|
||||||
return filemtime($this->path);
|
return @filemtime($this->path) ?: time();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(): void {
|
public function delete(): void {
|
||||||
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
[$folderKey, $fileName] = $this->split();
|
||||||
$rel = substr($this->path, strlen($base));
|
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
|
||||||
$parts = explode(DIRECTORY_SEPARATOR, $rel);
|
throw new Forbidden('No write access to delete this file');
|
||||||
$file = array_pop($parts);
|
}
|
||||||
$folder = empty($parts) ? 'root' : $parts[0];
|
if (!$this->canTouchOwnership($folderKey, $fileName)) {
|
||||||
FileModel::deleteFiles($folder, [$file]);
|
throw new Forbidden('You do not own this file');
|
||||||
|
}
|
||||||
|
\FileModel::deleteFiles($folderKey, [$fileName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setName($newName): void {
|
public function setName($newName): void {
|
||||||
throw new Forbidden('Renaming files not supported');
|
throw new Forbidden('Renaming files via WebDAV is not supported');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── IFile ───────────────────────────────────────────
|
// ── IFile ───────────────────────────────────────────
|
||||||
|
|
||||||
public function get() {
|
public function get() {
|
||||||
|
[$folderKey, $fileName] = $this->split();
|
||||||
|
$canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey);
|
||||||
|
if (!$canFull) {
|
||||||
|
// own-only?
|
||||||
|
if (!\ACL::hasGrant($this->user, $folderKey, 'read_own') || !$this->isOwner($folderKey, $fileName)) {
|
||||||
|
throw new Forbidden('No view access to this file');
|
||||||
|
}
|
||||||
|
}
|
||||||
return fopen($this->path, 'rb');
|
return fopen($this->path, 'rb');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function put($data): ?string {
|
public function put($data): ?string {
|
||||||
// 1) Save incoming data
|
[$folderKey, $fileName] = $this->split();
|
||||||
|
|
||||||
|
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
|
||||||
|
throw new Forbidden('No write access to this folder');
|
||||||
|
}
|
||||||
|
if (!empty($this->perms['disableUpload']) && !$this->isAdmin) {
|
||||||
|
throw new Forbidden('Uploads are disabled for your account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If overwriting existing file, enforce ownership for non-admin unless bypassOwnership
|
||||||
|
$exists = is_file($this->path);
|
||||||
|
$bypass = !empty($this->perms['bypassOwnership']);
|
||||||
|
if ($exists && !$this->isAdmin && !$bypass && !$this->isOwner($folderKey, $fileName)) {
|
||||||
|
throw new Forbidden('You do not own the target file');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write data
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
$this->path,
|
$this->path,
|
||||||
is_resource($data) ? stream_get_contents($data) : (string)$data
|
is_resource($data) ? stream_get_contents($data) : (string)$data
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2) Update metadata with CurrentUser
|
// Update metadata (uploader on first write; modified every write)
|
||||||
$this->updateMetadata();
|
$this->updateMetadata($folderKey, $fileName);
|
||||||
|
|
||||||
// 3) Flush to client fast
|
|
||||||
if (function_exists('fastcgi_finish_request')) {
|
if (function_exists('fastcgi_finish_request')) {
|
||||||
fastcgi_finish_request();
|
fastcgi_finish_request();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null; // no ETag
|
return null; // no ETag
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSize(): int {
|
public function getSize(): int {
|
||||||
return filesize($this->path);
|
return @filesize($this->path) ?: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getETag(): string {
|
public function getETag(): string {
|
||||||
return '"' . md5($this->getLastModified() . $this->getSize()) . '"';
|
return '"' . md5(($this->getLastModified() ?: 0) . ':' . ($this->getSize() ?: 0)) . '"';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getContentType(): ?string {
|
public function getContentType(): ?string {
|
||||||
return mime_content_type($this->path) ?: null;
|
return @mime_content_type($this->path) ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Metadata helper ───────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function updateMetadata(): void {
|
private function split(): array {
|
||||||
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
$rel = substr($this->path, strlen($base));
|
$rel = ltrim(str_replace('\\','/', substr($this->path, strlen($base))), '/');
|
||||||
$parts = explode(DIRECTORY_SEPARATOR, $rel);
|
$parts = explode('/', $rel);
|
||||||
$fileName = array_pop($parts);
|
$file = array_pop($parts);
|
||||||
$folder = empty($parts) ? 'root' : $parts[0];
|
$folder = empty($parts) ? 'root' : implode('/', $parts);
|
||||||
|
return [$folder, $file];
|
||||||
|
}
|
||||||
|
|
||||||
$metaFile = META_DIR
|
private function metaFile(string $folderKey): string {
|
||||||
. ($folder === 'root'
|
return META_DIR . (
|
||||||
? 'root_metadata.json'
|
$folderKey === 'root'
|
||||||
: str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json');
|
? 'root_metadata.json'
|
||||||
|
: str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$metadata = [];
|
private function loadMeta(string $folderKey): array {
|
||||||
if (file_exists($metaFile)) {
|
$mf = $this->metaFile($folderKey);
|
||||||
$decoded = json_decode(file_get_contents($metaFile), true);
|
if (!is_file($mf)) return [];
|
||||||
if (is_array($decoded)) {
|
$d = json_decode(@file_get_contents($mf), true);
|
||||||
$metadata = $decoded;
|
return is_array($d) ? $d : [];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
private function saveMeta(string $folderKey, array $meta): void {
|
||||||
|
@file_put_contents($this->metaFile($folderKey), json_encode($meta, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isOwner(string $folderKey, string $fileName): bool {
|
||||||
|
$meta = $this->loadMeta($folderKey);
|
||||||
|
return isset($meta[$fileName]['uploader']) &&
|
||||||
|
strcasecmp((string)$meta[$fileName]['uploader'], $this->user) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canTouchOwnership(string $folderKey, string $fileName): bool {
|
||||||
|
if ($this->isAdmin || !empty($this->perms['bypassOwnership'])) return true;
|
||||||
|
return $this->isOwner($folderKey, $fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateMetadata(string $folderKey, string $fileName): void {
|
||||||
|
$meta = $this->loadMeta($folderKey);
|
||||||
$now = date(DATE_TIME_FORMAT);
|
$now = date(DATE_TIME_FORMAT);
|
||||||
$uploaded = $metadata[$fileName]['uploaded'] ?? $now;
|
$uploaded = $meta[$fileName]['uploaded'] ?? $now;
|
||||||
$uploader = CurrentUser::get();
|
$uploader = CurrentUser::get() ?: $this->user;
|
||||||
|
|
||||||
$metadata[$fileName] = [
|
$meta[$fileName] = [
|
||||||
'uploaded' => $uploaded,
|
'uploaded' => $uploaded,
|
||||||
'modified' => $now,
|
'modified' => $now,
|
||||||
'uploader' => $uploader,
|
'uploader' => $uploader,
|
||||||
];
|
];
|
||||||
|
$this->saveMeta($folderKey, $meta);
|
||||||
file_put_contents($metaFile, json_encode($metadata, JSON_PRETTY_PRINT));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
118
start.sh
118
start.sh
@@ -1,35 +1,70 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
umask 002
|
||||||
echo "🚀 Running start.sh..."
|
echo "🚀 Running start.sh..."
|
||||||
|
|
||||||
# 1) Token‐key warning
|
# ──────────────────────────────────────────────────────────────
|
||||||
if [ "${PERSISTENT_TOKENS_KEY}" = "default_please_change_this_key" ]; then
|
# 0) If NOT root, we can't remap/chown. Log a hint and skip those parts.
|
||||||
echo "⚠️ WARNING: Using default persistent tokens key—override for production."
|
# If root, remap www-data to PUID/PGID and (optionally) chown data dirs.
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "[startup] Running as non-root. Skipping PUID/PGID remap and chown."
|
||||||
|
echo "[startup] Tip: remove '--user' and set PUID/PGID env vars instead."
|
||||||
|
else
|
||||||
|
# Remap www-data to match provided PUID/PGID (e.g., Unraid 99:100 or 1000:1000)
|
||||||
|
if [ -n "${PGID:-}" ]; then
|
||||||
|
current_gid="$(getent group www-data | cut -d: -f3 || true)"
|
||||||
|
if [ "${current_gid}" != "${PGID}" ]; then
|
||||||
|
groupmod -o -g "${PGID}" www-data || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "${PUID:-}" ]; then
|
||||||
|
current_uid="$(id -u www-data 2>/dev/null || echo '')"
|
||||||
|
target_gid="${PGID:-$(getent group www-data | cut -d: -f3)}"
|
||||||
|
if [ "${current_uid}" != "${PUID}" ]; then
|
||||||
|
usermod -o -u "${PUID}" -g "${target_gid}" www-data || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Optional: normalize ownership on data dirs (good for first run on existing shares)
|
||||||
|
if [ "${CHOWN_ON_START:-true}" = "true" ]; then
|
||||||
|
echo "[startup] Normalizing ownership on uploads/metadata..."
|
||||||
|
chown -R www-data:www-data /var/www/metadata /var/www/uploads || echo "[startup] chown failed (continuing)"
|
||||||
|
chmod -R u+rwX /var/www/metadata /var/www/uploads || echo "[startup] chmod failed (continuing)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 1) Token‐key warning (guarded for -u)
|
||||||
|
if [ "${PERSISTENT_TOKENS_KEY:-}" = "default_please_change_this_key" ] || [ -z "${PERSISTENT_TOKENS_KEY:-}" ]; then
|
||||||
|
echo "⚠️ WARNING: Using default/empty persistent tokens key—override for production."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2) Update config.php based on environment variables
|
# 2) Update config.php based on environment variables
|
||||||
CONFIG_FILE="/var/www/config/config.php"
|
CONFIG_FILE="/var/www/config/config.php"
|
||||||
if [ -f "${CONFIG_FILE}" ]; then
|
if [ -f "${CONFIG_FILE}" ]; then
|
||||||
echo "🔄 Updating config.php from env vars..."
|
echo "🔄 Updating config.php from env vars..."
|
||||||
[ -n "${TIMEZONE:-}" ] && sed -i "s|define('TIMEZONE',[[:space:]]*'[^']*');|define('TIMEZONE', '${TIMEZONE}');|" "${CONFIG_FILE}"
|
[ -n "${TIMEZONE:-}" ] && sed -i "s|define('TIMEZONE',[[:space:]]*'[^']*');|define('TIMEZONE', '${TIMEZONE}');|" "${CONFIG_FILE}"
|
||||||
[ -n "${DATE_TIME_FORMAT:-}" ] && sed -i "s|define('DATE_TIME_FORMAT',[[:space:]]*'[^']*');|define('DATE_TIME_FORMAT', '${DATE_TIME_FORMAT}');|" "${CONFIG_FILE}"
|
[ -n "${DATE_TIME_FORMAT:-}" ] && sed -i "s|define('DATE_TIME_FORMAT',[[:space:]]*'[^']*');|define('DATE_TIME_FORMAT', '${DATE_TIME_FORMAT}');|" "${CONFIG_FILE}"
|
||||||
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||||
sed -i "s|define('TOTAL_UPLOAD_SIZE',[[:space:]]*'[^']*');|define('TOTAL_UPLOAD_SIZE', '${TOTAL_UPLOAD_SIZE}');|" "${CONFIG_FILE}"
|
sed -i "s|define('TOTAL_UPLOAD_SIZE',[[:space:]]*'[^']*');|define('TOTAL_UPLOAD_SIZE', '${TOTAL_UPLOAD_SIZE}');|" "${CONFIG_FILE}"
|
||||||
fi
|
fi
|
||||||
[ -n "${SECURE:-}" ] && sed -i "s|\$envSecure = getenv('SECURE');|\$envSecure = '${SECURE}';|" "${CONFIG_FILE}"
|
[ -n "${SECURE:-}" ] && sed -i "s|\$envSecure = getenv('SECURE');|\$envSecure = '${SECURE}';|" "${CONFIG_FILE}"
|
||||||
[ -n "${SHARE_URL:-}" ] && sed -i "s|define('SHARE_URL',[[:space:]]*'[^']*');|define('SHARE_URL', '${SHARE_URL}');|" "${CONFIG_FILE}"
|
# NOTE: SHARE_URL is read from getenv in PHP; no sed needed.
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2.1) Prepare metadata/log for Apache logs
|
# 2.1) Prepare metadata/log & sessions
|
||||||
mkdir -p /var/www/metadata/log
|
mkdir -p /var/www/metadata/log
|
||||||
chown www-data:www-data /var/www/metadata/log
|
chown www-data:www-data /var/www/metadata/log
|
||||||
chmod 775 /var/www/metadata/log
|
chmod 775 /var/www/metadata/log
|
||||||
|
: > /var/www/metadata/log/error.log
|
||||||
|
: > /var/www/metadata/log/access.log
|
||||||
|
chown www-data:www-data /var/www/metadata/log/*.log
|
||||||
|
|
||||||
mkdir -p /var/www/sessions
|
mkdir -p /var/www/sessions
|
||||||
chown www-data:www-data /var/www/sessions
|
chown www-data:www-data /var/www/sessions
|
||||||
chmod 700 /var/www/sessions
|
chmod 700 /var/www/sessions
|
||||||
|
|
||||||
# 2.2) Prepare other dynamic dirs
|
# 2.2) Prepare dynamic dirs (uploads/users/metadata)
|
||||||
for d in uploads users metadata; do
|
for d in uploads users metadata; do
|
||||||
tgt="/var/www/${d}"
|
tgt="/var/www/${d}"
|
||||||
mkdir -p "${tgt}"
|
mkdir -p "${tgt}"
|
||||||
@@ -37,7 +72,7 @@ for d in uploads users metadata; do
|
|||||||
chmod 775 "${tgt}"
|
chmod 775 "${tgt}"
|
||||||
done
|
done
|
||||||
|
|
||||||
# 3) Ensure PHP config dir & set upload limits
|
# 3) Ensure PHP conf dir & set upload limits
|
||||||
mkdir -p /etc/php/8.3/apache2/conf.d
|
mkdir -p /etc/php/8.3/apache2/conf.d
|
||||||
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||||
echo "🔄 Setting PHP upload limits to ${TOTAL_UPLOAD_SIZE}"
|
echo "🔄 Setting PHP upload limits to ${TOTAL_UPLOAD_SIZE}"
|
||||||
@@ -49,8 +84,7 @@ fi
|
|||||||
|
|
||||||
# 4) Adjust Apache LimitRequestBody
|
# 4) Adjust Apache LimitRequestBody
|
||||||
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||||
# convert to bytes
|
size_str="$(echo "${TOTAL_UPLOAD_SIZE}" | tr '[:upper:]' '[:lower:]')"
|
||||||
size_str=$(echo "${TOTAL_UPLOAD_SIZE}" | tr '[:upper:]' '[:lower:]')
|
|
||||||
case "${size_str: -1}" in
|
case "${size_str: -1}" in
|
||||||
g) factor=$((1024*1024*1024)); num=${size_str%g} ;;
|
g) factor=$((1024*1024*1024)); num=${size_str%g} ;;
|
||||||
m) factor=$((1024*1024)); num=${size_str%m} ;;
|
m) factor=$((1024*1024)); num=${size_str%m} ;;
|
||||||
@@ -73,29 +107,22 @@ EOF
|
|||||||
|
|
||||||
# 6) Override ports if provided
|
# 6) Override ports if provided
|
||||||
if [ -n "${HTTP_PORT:-}" ]; then
|
if [ -n "${HTTP_PORT:-}" ]; then
|
||||||
sed -i "s/^Listen 80$/Listen ${HTTP_PORT}/" /etc/apache2/ports.conf
|
sed -i "s/^Listen 80$/Listen ${HTTP_PORT}/" /etc/apache2/ports.conf || true
|
||||||
sed -i "s/<VirtualHost \*:80>/<VirtualHost *:${HTTP_PORT}>/" /etc/apache2/sites-available/000-default.conf
|
sed -i "s/<VirtualHost \*:80>/<VirtualHost *:${HTTP_PORT}>/" /etc/apache2/sites-available/000-default.conf || true
|
||||||
fi
|
fi
|
||||||
if [ -n "${HTTPS_PORT:-}" ]; then
|
if [ -n "${HTTPS_PORT:-}" ]; then
|
||||||
sed -i "s/^Listen 443$/Listen ${HTTPS_PORT}/" /etc/apache2/ports.conf
|
sed -i "s/^Listen 443$/Listen ${HTTPS_PORT}/" /etc/apache2/ports.conf || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 7) Set ServerName
|
# 7) Set ServerName (idempotent)
|
||||||
if [ -n "${SERVER_NAME:-}" ]; then
|
SN="${SERVER_NAME:-FileRise}"
|
||||||
echo "ServerName ${SERVER_NAME}" >> /etc/apache2/apache2.conf
|
if grep -qE '^ServerName\s' /etc/apache2/apache2.conf; then
|
||||||
|
sed -i "s|^ServerName .*|ServerName ${SN}|" /etc/apache2/apache2.conf
|
||||||
else
|
else
|
||||||
echo "ServerName FileRise" >> /etc/apache2/apache2.conf
|
echo "ServerName ${SN}" >> /etc/apache2/apache2.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 8) Prepare dynamic data directories with least privilege
|
# 8) Initialize persistent files if absent
|
||||||
for d in uploads users metadata; do
|
|
||||||
tgt="/var/www/${d}"
|
|
||||||
mkdir -p "${tgt}"
|
|
||||||
chown www-data:www-data "${tgt}"
|
|
||||||
chmod 775 "${tgt}"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 9) Initialize persistent files if absent
|
|
||||||
if [ ! -f /var/www/users/users.txt ]; then
|
if [ ! -f /var/www/users/users.txt ]; then
|
||||||
echo "" > /var/www/users/users.txt
|
echo "" > /var/www/users/users.txt
|
||||||
chown www-data:www-data /var/www/users/users.txt
|
chown www-data:www-data /var/www/users/users.txt
|
||||||
@@ -108,5 +135,38 @@ if [ ! -f /var/www/metadata/createdTags.json ]; then
|
|||||||
chmod 664 /var/www/metadata/createdTags.json
|
chmod 664 /var/www/metadata/createdTags.json
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 8.5) Harden scan script perms (only if root)
|
||||||
|
if [ -f /var/www/scripts/scan_uploads.php ] && [ "$(id -u)" -eq 0 ]; then
|
||||||
|
chown root:root /var/www/scripts/scan_uploads.php
|
||||||
|
chmod 0644 /var/www/scripts/scan_uploads.php
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 9) One-shot scan when the container starts (opt-in via SCAN_ON_START)
|
||||||
|
if [ "${SCAN_ON_START:-}" = "true" ]; then
|
||||||
|
echo "[startup] Scanning uploads directory to build metadata..."
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
if command -v runuser >/dev/null 2>&1; then
|
||||||
|
runuser -u www-data -- /usr/bin/php /var/www/scripts/scan_uploads.php || echo "[startup] Scan failed (continuing)"
|
||||||
|
else
|
||||||
|
su -s /bin/sh -c "/usr/bin/php /var/www/scripts/scan_uploads.php" www-data || echo "[startup] Scan failed (continuing)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Non-root fallback: run as current user (permissions may limit writes)
|
||||||
|
/usr/bin/php /var/www/scripts/scan_uploads.php || echo "[startup] Scan failed (continuing)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 9.6) Stream Apache logs to the container console (optional toggle)
|
||||||
|
LOG_STREAM="${LOG_STREAM:-error}"
|
||||||
|
case "${LOG_STREAM,,}" in
|
||||||
|
none) STREAM_ERR=false; STREAM_ACC=false ;;
|
||||||
|
access) STREAM_ERR=false; STREAM_ACC=true ;;
|
||||||
|
both) STREAM_ERR=true; STREAM_ACC=true ;;
|
||||||
|
error|*)STREAM_ERR=true; STREAM_ACC=false ;;
|
||||||
|
esac
|
||||||
|
|
||||||
echo "🔥 Starting Apache..."
|
echo "🔥 Starting Apache..."
|
||||||
|
# Stream only the chosen logs; -n0 = don't dump history, -F = follow across rotations/creation
|
||||||
|
[ "${STREAM_ERR}" = "true" ] && tail -n0 -F /var/www/metadata/log/error.log 2>/dev/null &
|
||||||
|
[ "${STREAM_ACC}" = "true" ] && tail -n0 -F /var/www/metadata/log/access.log 2>/dev/null &
|
||||||
exec apachectl -D FOREGROUND
|
exec apachectl -D FOREGROUND
|
||||||
Reference in New Issue
Block a user