Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be605b4522 | ||
|
|
090286164d | ||
|
|
dc1649ace3 | ||
|
|
b6d86b7896 | ||
|
|
25ce6a76be | ||
|
|
f2ab2a96bc | ||
|
|
c22c8e0f34 | ||
|
|
070515e7a6 | ||
|
|
7a0f4ddbb4 | ||
|
|
e1c15eb95a | ||
|
|
2400dcb9eb | ||
|
|
c717f8be60 | ||
|
|
3dd5a8664a | ||
|
|
0cb47b4054 |
@@ -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
|
||||||
1
.github/workflows/sync-changelog.yml
vendored
1
.github/workflows/sync-changelog.yml
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
---
|
||||||
name: Sync Changelog to Docker Repo
|
name: Sync Changelog to Docker Repo
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/data/
|
||||||
277
CHANGELOG.md
277
CHANGELOG.md
@@ -1,5 +1,264 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 10/19/2025 (v1.5.1)
|
||||||
|
|
||||||
|
fix(config/ui): serve safe public config to non-admins; init early; gate trash UI to admins; dynamic title; demo toast (closes #56)
|
||||||
|
|
||||||
|
Regular users were getting 403s from `/api/admin/getConfig.php`, breaking header title and login option rendering. Issue #56 tracks this.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
|
||||||
|
- **AdminController::getConfig**
|
||||||
|
- Return a **public, non-sensitive subset** of config for everyone (incl. unauthenticated and non-admin users): `header_title`, minimal `loginOptions` (disable* flags only), `globalOtpauthUrl`, `enableWebDAV`, `sharedMaxUploadSize`, and OIDC `providerUrl`/`redirectUri`.
|
||||||
|
- For **admins**, merge in admin-only fields (`authBypass`, `authHeaderName`).
|
||||||
|
- Never expose secrets or client IDs.
|
||||||
|
- **auth.js**
|
||||||
|
- `loadAdminConfigFunc()` now robustly handles empty/204 responses, writes sane defaults, and sets `document.title` from `header_title`.
|
||||||
|
- `showToast()` override: on `demo.filerise.net` shows a longer demo-creds toast; keeps TOTP “don’t nag” behavior.
|
||||||
|
- **main.js**
|
||||||
|
- Call `loadAdminConfigFunc()` early during app init.
|
||||||
|
- Run `setupTrashRestoreDelete()` **only for admins** (based on `localStorage.isAdmin`).
|
||||||
|
- **adminPanel.js**
|
||||||
|
- Bump visible version to **v1.5.1**.
|
||||||
|
- **index.html**
|
||||||
|
- Keep `<title>FileRise</title>` static; runtime title now driven by `loadAdminConfigFunc()`.
|
||||||
|
|
||||||
|
### Security v1.5.1
|
||||||
|
|
||||||
|
- Prevents info disclosure by strictly limiting non-admin fields.
|
||||||
|
- Avoids noisy 403 for regular users while keeping admin-only data protected.
|
||||||
|
|
||||||
|
### QA
|
||||||
|
|
||||||
|
- As a non-admin:
|
||||||
|
- Opening the app no longer triggers a 403 on `getConfig.php`.
|
||||||
|
- Header title and login options render; document tab title updates to configured `header_title`.
|
||||||
|
- Trash/restore UI is not initialized.
|
||||||
|
- As an admin:
|
||||||
|
- Admin Panel loads extra fields; trash/restore UI initializes.
|
||||||
|
- Title updates correctly.
|
||||||
|
- On `demo.filerise.net`:
|
||||||
|
- Pre-login toast shows demo credentials for ~12s.
|
||||||
|
|
||||||
|
Closes #56.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
## Changes 10/4/2025 v1.3.13
|
||||||
|
|
||||||
fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash
|
fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash
|
||||||
@@ -18,6 +277,8 @@ chore(scanner): skip profile_pics subtree during scans
|
|||||||
- Avoids indexing internal avatar images (folder already hidden in UI)
|
- Avoids indexing internal avatar images (folder already hidden in UI)
|
||||||
- Reduces scan noise and metadata churn; keeps firmware/other content indexed
|
- Reduces scan noise and metadata churn; keeps firmware/other content indexed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 10/4/2025 v1.3.12
|
## Changes 10/4/2025 v1.3.12
|
||||||
|
|
||||||
Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
|
Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
|
||||||
@@ -26,6 +287,8 @@ Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
|
|||||||
- Added CHOWN_ON_START env to control recursive chown (default true; turn off after first run)
|
- Added CHOWN_ON_START env to control recursive chown (default true; turn off after first run)
|
||||||
- SCAN_ON_START unchanged, with non-root fallback
|
- SCAN_ON_START unchanged, with non-root fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 10/4/2025 v1.3.11
|
## Changes 10/4/2025 v1.3.11
|
||||||
|
|
||||||
Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
|
Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
|
||||||
@@ -33,6 +296,8 @@ 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)
|
- Remove no-op sed of SHARE_URL from start.sh (env already used)
|
||||||
- Build default share link with correct scheme (http/https, proxy-aware)
|
- Build default share link with correct scheme (http/https, proxy-aware)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 10/4/2025 v1.3.10
|
## Changes 10/4/2025 v1.3.10
|
||||||
|
|
||||||
Fix: index externally added files on startup; harden start.sh (#46)
|
Fix: index externally added files on startup; harden start.sh (#46)
|
||||||
@@ -45,6 +310,8 @@ Fix: index externally added files on startup; harden start.sh (#46)
|
|||||||
|
|
||||||
No behavior change unless SCAN_ON_START=true.
|
No behavior change unless SCAN_ON_START=true.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 5/27/2025 v1.3.9
|
## Changes 5/27/2025 v1.3.9
|
||||||
|
|
||||||
- Support for mounting CIFS (SMB) network shares via Docker volumes
|
- Support for mounting CIFS (SMB) network shares via Docker volumes
|
||||||
@@ -138,7 +405,7 @@ No behavior change unless SCAN_ON_START=true.
|
|||||||
|
|
||||||
- **Folder strip in file list**
|
- **Folder strip in file list**
|
||||||
- `loadFileList` now fetches sub-folders in parallel from `/api/folder/getFolderList.php`.
|
- `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`.
|
- 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).
|
- Injects a new `.folder-strip-container` just below the Files In above (summary + slider).
|
||||||
- Clicking a folder in the strip updates:
|
- Clicking a folder in the strip updates:
|
||||||
- the breadcrumb (via `updateBreadcrumbTitle`)
|
- the breadcrumb (via `updateBreadcrumbTitle`)
|
||||||
@@ -186,7 +453,7 @@ No behavior change unless SCAN_ON_START=true.
|
|||||||
|
|
||||||
- Moved previously standalone header buttons into the dropdown menu:
|
- Moved previously standalone header buttons into the dropdown menu:
|
||||||
- **User Panel** opens the modal
|
- **User Panel** opens the modal
|
||||||
- **Admin Panel** only shown when `data.isAdmin` *and* on `demo.filerise.net`
|
- **Admin Panel** only shown when `data.isAdmin` and on `demo.filerise.net`
|
||||||
- **API Docs** calls `openApiModal()`
|
- **API Docs** calls `openApiModal()`
|
||||||
- **Logout** calls `triggerLogout()`
|
- **Logout** calls `triggerLogout()`
|
||||||
- Each menu item now has a matching Material icon (e.g. `person`, `admin_panel_settings`, `description`, `logout`).
|
- Each menu item now has a matching Material icon (e.g. `person`, `admin_panel_settings`, `description`, `logout`).
|
||||||
@@ -307,7 +574,7 @@ No behavior change unless SCAN_ON_START=true.
|
|||||||
- 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
|
||||||
|
|
||||||
@@ -349,7 +616,7 @@ No behavior change unless SCAN_ON_START=true.
|
|||||||
- 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
|
### Security old
|
||||||
|
|
||||||
- **Admin API**: `getConfig.php` now returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
|
- **Admin API**: `getConfig.php` now returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
|
||||||
|
|
||||||
@@ -378,7 +645,7 @@ No behavior change unless SCAN_ON_START=true.
|
|||||||
|
|
||||||
- **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.
|
||||||
|
|||||||
123
README.md
123
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,43 +23,46 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -89,9 +101,9 @@ This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
|||||||
**Notes**
|
**Notes**
|
||||||
|
|
||||||
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
|
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
|
||||||
- `CHOWN_ON_START=true` is recommended on **first run** to normalize ownership of existing trees. Set to **false** later for faster restarts.
|
- `CHOWN_ON_START=true` is recommended on **first run**. Set to **false** later for faster restarts.
|
||||||
- `SCAN_ON_START=true` runs a one-time index of files added outside the UI so their metadata appears.
|
- `SCAN_ON_START=true` indexes files added outside the UI so their metadata appears.
|
||||||
- `SHARE_URL` is optional; leave blank to auto-detect from the current host/scheme. You can set it to your site root (e.g., `https://files.example.com`) or directly to the full endpoint.
|
- `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.
|
- Set `SECURE="true"` if you serve via HTTPS at your proxy layer.
|
||||||
|
|
||||||
**Verify ownership mapping (optional)**
|
**Verify ownership mapping (optional)**
|
||||||
@@ -131,17 +143,17 @@ services:
|
|||||||
- ./metadata:/var/www/metadata
|
- ./metadata:/var/www/metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
FileRise will be accessible at `http://localhost:8080` (or your server’s IP).
|
Access at `http://localhost:8080` (or your server’s IP).
|
||||||
The example also sets a custom `PERSISTENT_TOKENS_KEY` (used to encrypt “Remember Me” tokens)—change it to a strong random string.
|
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string.
|
||||||
|
|
||||||
**First-time Setup**
|
**First-time Setup**
|
||||||
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. After logging in, use **User Management** to add more users.
|
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. Then use **User Management** to add more users.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2) Manual Installation (PHP/Apache)
|
### 2) Manual Installation (PHP/Apache)
|
||||||
|
|
||||||
If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
|
If you prefer a traditional web server (LAMP stack or similar):
|
||||||
|
|
||||||
**Requirements**
|
**Requirements**
|
||||||
|
|
||||||
@@ -158,7 +170,6 @@ git clone https://github.com/error311/FileRise.git
|
|||||||
Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine.
|
Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine.
|
||||||
|
|
||||||
**Composer (if applicable)**
|
**Composer (if applicable)**
|
||||||
If you use optional features requiring Composer libraries, run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer install
|
composer install
|
||||||
@@ -178,40 +189,50 @@ chmod -R 775 uploads users metadata
|
|||||||
|
|
||||||
**Configuration**
|
**Configuration**
|
||||||
|
|
||||||
Open `config.php` and consider:
|
Edit `config.php`:
|
||||||
|
|
||||||
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
|
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
|
||||||
- `TOTAL_UPLOAD_SIZE` (also ensure your PHP `upload_max_filesize` & `post_max_size` meet/exceed this).
|
- `TOTAL_UPLOAD_SIZE` (ensure PHP `upload_max_filesize` and `post_max_size` meet/exceed this).
|
||||||
- `PERSISTENT_TOKENS_KEY` set to a unique secret if using “Remember Me”.
|
- `PERSISTENT_TOKENS_KEY` for “Remember Me” tokens.
|
||||||
|
|
||||||
**Share links base URL**
|
**Share link base URL**
|
||||||
|
|
||||||
- You can set **`SHARE_URL`** via your web server environment variables (preferred),
|
- Set **`SHARE_URL`** via web-server env vars (preferred),
|
||||||
**or** keep using `BASE_URL` in `config.php` as a fallback for manual installs.
|
**or** keep using `BASE_URL` in `config.php` as a fallback.
|
||||||
- If neither is set, FileRise auto-detects from the current host/scheme.
|
- If neither is set, FileRise auto-detects from the current host/scheme.
|
||||||
|
|
||||||
**Web Server Config**
|
**Web server config**
|
||||||
|
|
||||||
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
|
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
|
||||||
- Nginx/other: replicate the basic protections (no directory listing, deny sensitive files). See Wiki for examples.
|
- Nginx/other: replicate basic protections (no directory listing, deny sensitive files). See Wiki for examples.
|
||||||
|
|
||||||
Now browse to your FileRise URL; you’ll be prompted to create the Admin user on first load.
|
Browse to your FileRise URL; you’ll be prompted to create the Admin user on first load.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick‑start: Mount via WebDAV
|
## Unraid
|
||||||
|
|
||||||
Once FileRise is running, you must enable WebDAV in admin panel to access it.
|
- 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…**
|
||||||
@@ -222,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.
|
||||||
@@ -238,33 +259,33 @@ 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!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -272,7 +293,9 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
|
|||||||
|
|
||||||
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) – (Announcement and user feedback thread).
|
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) – (Announcement and user feedback thread).
|
||||||
- **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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -305,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
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ 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,18 +75,29 @@ 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)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$content = file_get_contents($permissionsFile);
|
$content = file_get_contents($permissionsFile);
|
||||||
$decrypted = decryptData($content, $encryptionKey);
|
$decrypted = decryptData($content, $encryptionKey);
|
||||||
$json = ($decrypted !== false) ? $decrypted : $content;
|
$json = ($decrypted !== false) ? $decrypted : $content;
|
||||||
$perms = json_decode($json, true);
|
$permsAll = json_decode($json, true);
|
||||||
if (is_array($perms) && isset($perms[$username])) {
|
|
||||||
return !empty($perms[$username]) ? $perms[$username] : false;
|
if (!is_array($permsAll)) {
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
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
|
||||||
$envSecure = getenv('SECURE');
|
$envSecure = getenv('SECURE');
|
||||||
$secure = ($envSecure !== false)
|
$secure = ($envSecure !== false)
|
||||||
@@ -91,11 +107,14 @@ $secure = ($envSecure !== false)
|
|||||||
// 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
|
/**
|
||||||
|
* 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) {
|
||||||
session_set_cookie_params([
|
session_set_cookie_params([
|
||||||
'lifetime' => $sessionLifetime,
|
'lifetime' => $sessionLifetime,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
@@ -105,9 +124,20 @@ session_set_cookie_params([
|
|||||||
'samesite' => 'Lax'
|
'samesite' => 'Lax'
|
||||||
]);
|
]);
|
||||||
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
||||||
|
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
|
||||||
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 = [];
|
||||||
|
|||||||
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}]}']);
|
||||||
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);
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title data-i18n-key="title">FileRise</title>
|
<title>FileRise</title>
|
||||||
<link rel="icon" type="image/png" href="/assets/logo.png">
|
<link rel="icon" type="image/png" href="/assets/logo.png">
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||||
<meta name="csrf-token" content="">
|
<meta name="csrf-token" content="">
|
||||||
|
|||||||
@@ -1,11 +1,35 @@
|
|||||||
|
// adminPanel.js
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js';
|
||||||
import { loadAdminConfigFunc } from './auth.js';
|
import { loadAdminConfigFunc } from './auth.js';
|
||||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||||
import { sendRequest } from './networkUtils.js';
|
import { sendRequest } from './networkUtils.js';
|
||||||
|
|
||||||
const version = "v1.3.13";
|
const version = "v1.5.1";
|
||||||
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
||||||
|
|
||||||
|
// Translate with fallback: if t(key) just echos the key, use a readable string.
|
||||||
|
const tf = (key, fallback) => {
|
||||||
|
const v = t(key);
|
||||||
|
return (v && v !== key) ? v : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- tiny robust JSON helper ---
|
||||||
|
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 ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
// ————— Inject updated styles —————
|
// ————— Inject updated styles —————
|
||||||
(function () {
|
(function () {
|
||||||
if (document.getElementById('adminPanelStyles')) return;
|
if (document.getElementById('adminPanelStyles')) return;
|
||||||
@@ -16,8 +40,10 @@ const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;
|
|||||||
#adminPanelModal .modal-content {
|
#adminPanelModal .modal-content {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
|
background: #fff !important;
|
||||||
|
color: #000 !important;
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small phones: 90% width */
|
/* Small phones: 90% width */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
#adminPanelModal .modal-content {
|
#adminPanelModal .modal-content {
|
||||||
@@ -25,91 +51,96 @@ const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;
|
|||||||
max-width: none !important;
|
max-width: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark-mode fixes */
|
/* Dark-mode fixes */
|
||||||
body.dark-mode #adminPanelModal .modal-content {
|
body.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
|
||||||
border-color: #555 !important;
|
body.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
|
||||||
}
|
|
||||||
|
|
||||||
/* enforce light‐mode styling */
|
|
||||||
#adminPanelModal .modal-content {
|
|
||||||
max-width: 1100px;
|
|
||||||
width: 50%;
|
|
||||||
background: #fff !important;
|
|
||||||
color: #000 !important;
|
|
||||||
border: 1px solid #ccc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* enforce dark‐mode styling */
|
|
||||||
body.dark-mode #adminPanelModal .modal-content {
|
|
||||||
background: #2c2c2c !important;
|
|
||||||
color: #e0e0e0 !important;
|
|
||||||
border-color: #555 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* form controls in dark */
|
|
||||||
body.dark-mode .form-control {
|
|
||||||
background-color: #333;
|
|
||||||
border-color: #555;
|
|
||||||
color: #eee;
|
|
||||||
}
|
|
||||||
body.dark-mode .form-control::placeholder { color:#888; }
|
body.dark-mode .form-control::placeholder { color:#888; }
|
||||||
|
|
||||||
/* Section headers */
|
/* Section headers */
|
||||||
.section-header {
|
.section-header {
|
||||||
background: #f5f5f5;
|
background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:4px; font-weight:bold;
|
||||||
padding: 10px 15px;
|
display:flex; align-items:center; justify-content:space-between; margin-top:16px;
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: bold;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
}
|
||||||
.section-header:first-of-type { margin-top:0; }
|
.section-header:first-of-type { margin-top:0; }
|
||||||
.section-header.collapsed .material-icons { transform:rotate(-90deg); }
|
.section-header.collapsed .material-icons { transform:rotate(-90deg); }
|
||||||
.section-header .material-icons { transition:transform .3s; color:#444; }
|
.section-header .material-icons { transition:transform .3s; color:#444; }
|
||||||
|
body.dark-mode .section-header { background:#3a3a3a; color:#eee; }
|
||||||
body.dark-mode .section-header {
|
|
||||||
background: #3a3a3a;
|
|
||||||
color: #eee;
|
|
||||||
}
|
|
||||||
body.dark-mode .section-header .material-icons { color:#ccc; }
|
body.dark-mode .section-header .material-icons { color:#ccc; }
|
||||||
|
|
||||||
/* Hidden by default */
|
/* Hidden by default */
|
||||||
.section-content {
|
.section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; }
|
||||||
display: none;
|
|
||||||
margin-left: 20px;
|
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Close button */
|
/* Close button */
|
||||||
#adminPanelModal .editor-close-btn {
|
#adminPanelModal .editor-close-btn {
|
||||||
position: absolute; top:10px; right:10px;
|
position:absolute; top:10px; right:10px; display:flex; align-items:center; justify-content:center;
|
||||||
display:flex; align-items:center; justify-content:center;
|
font-size:20px; font-weight:bold; cursor:pointer; z-index:1000; width:32px; height:32px; border-radius:50%;
|
||||||
font-size:20px; font-weight:bold; cursor:pointer;
|
text-align:center; line-height:30px; color:#ff4d4d; background:rgba(255,255,255,0.9);
|
||||||
z-index:1000; width:32px; height:32px; border-radius:50%;
|
|
||||||
text-align:center; line-height:30px;
|
|
||||||
color:#ff4d4d; background:rgba(255,255,255,0.9);
|
|
||||||
border:2px solid transparent; transition:all .3s;
|
border:2px solid transparent; transition:all .3s;
|
||||||
}
|
}
|
||||||
#adminPanelModal .editor-close-btn:hover {
|
#adminPanelModal .editor-close-btn:hover { color:#fff; background:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); }
|
||||||
color:white; background:#ff4d4d;
|
body.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; }
|
||||||
box-shadow:0 0 6px rgba(255,77,77,.8);
|
|
||||||
transform:scale(1.05);
|
|
||||||
}
|
|
||||||
body.dark-mode #adminPanelModal .editor-close-btn {
|
|
||||||
background:rgba(0,0,0,0.6);
|
|
||||||
color:#ff4d4d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action-row */
|
/* Action-row */
|
||||||
.action-row {
|
.action-row { display:flex; justify-content:space-between; margin-top:15px; }
|
||||||
display:flex;
|
|
||||||
justify-content:space-between;
|
/* ---------- Folder access editor ---------- */
|
||||||
margin-top:15px;
|
.folder-access-toolbar {
|
||||||
|
display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scroll area (header lives inside, sticky) */
|
||||||
|
.folder-access-list {
|
||||||
|
--col-perm: 84px; /* width of each permission column */
|
||||||
|
--col-folder-min: 340px; /* min width for folder names */
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0; /* no inner padding to keep grid aligned */
|
||||||
|
}
|
||||||
|
body.dark-mode .folder-access-list { border-color:#555; }
|
||||||
|
|
||||||
|
/* Shared grid for header + rows (MUST match) */
|
||||||
|
.folder-access-header,
|
||||||
|
.folder-access-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(var(--col-folder-min), 1fr) repeat(5, var(--col-perm));
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky header so it always aligns with the rows under the same scrollbar */
|
||||||
|
.folder-access-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
body.dark-mode .folder-access-header { background:#2c2c2c; }
|
||||||
|
|
||||||
|
/* Rows */
|
||||||
|
.folder-access-row { border-bottom: 1px solid rgba(0,0,0,0.06); }
|
||||||
|
.folder-access-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
/* Columns */
|
||||||
|
.perm-col { text-align:center; white-space:nowrap; }
|
||||||
|
.folder-access-header > div { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Folder label: show more of the path, ellipsis if needed */
|
||||||
|
.folder-badge {
|
||||||
|
display:inline-flex; align-items:center; gap:6px;
|
||||||
|
font-weight:600; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
|
||||||
|
min-width: 0; /* allow ellipsis in grid */
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted { opacity:.65; font-size:.9em; }
|
||||||
|
|
||||||
|
/* Tighter on small screens */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
@@ -173,7 +204,6 @@ function toggleSection(id) {
|
|||||||
const hdr = document.getElementById(id + "Header");
|
const hdr = document.getElementById(id + "Header");
|
||||||
const cnt = document.getElementById(id + "Content");
|
const cnt = document.getElementById(id + "Content");
|
||||||
const isCollapsedNow = hdr.classList.toggle("collapsed");
|
const isCollapsedNow = hdr.classList.toggle("collapsed");
|
||||||
// collapsed class present => hide; absent => show
|
|
||||||
cnt.style.display = isCollapsedNow ? "none" : "block";
|
cnt.style.display = isCollapsedNow ? "none" : "block";
|
||||||
if (!isCollapsedNow && id === "shareLinks") {
|
if (!isCollapsedNow && id === "shareLinks") {
|
||||||
loadShareLinksSection();
|
loadShareLinksSection();
|
||||||
@@ -184,23 +214,12 @@ function loadShareLinksSection() {
|
|||||||
const container = document.getElementById("shareLinksContent");
|
const container = document.getElementById("shareLinksContent");
|
||||||
container.textContent = t("loading") + "...";
|
container.textContent = t("loading") + "...";
|
||||||
|
|
||||||
// helper: fetch one metadata file, but never throw —
|
|
||||||
// on non-2xx (including 404) or network error, resolve to {}
|
|
||||||
function fetchMeta(fileName) {
|
function fetchMeta(fileName) {
|
||||||
return fetch(`/api/admin/readMetadata.php?file=${encodeURIComponent(fileName)}`, {
|
return fetch(`/api/admin/readMetadata.php?file=${encodeURIComponent(fileName)}`, {
|
||||||
credentials: "include"
|
credentials: "include"
|
||||||
})
|
})
|
||||||
.then(resp => {
|
.then(resp => resp.ok ? resp.json() : {})
|
||||||
if (!resp.ok) {
|
.catch(() => ({}));
|
||||||
// 404 or any other non-OK → treat as empty
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return resp.json();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// network failure, parse error, etc → also empty
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -208,7 +227,6 @@ function loadShareLinksSection() {
|
|||||||
fetchMeta("share_links.json")
|
fetchMeta("share_links.json")
|
||||||
])
|
])
|
||||||
.then(([folders, files]) => {
|
.then(([folders, files]) => {
|
||||||
// if *both* are empty, show "no shared links"
|
|
||||||
const hasAny = Object.keys(folders).length || Object.keys(files).length;
|
const hasAny = Object.keys(folders).length || Object.keys(files).length;
|
||||||
if (!hasAny) {
|
if (!hasAny) {
|
||||||
container.innerHTML = `<p>${t("no_shared_links_available")}</p>`;
|
container.innerHTML = `<p>${t("no_shared_links_available")}</p>`;
|
||||||
@@ -246,7 +264,6 @@ function loadShareLinksSection() {
|
|||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
// wire up delete buttons
|
|
||||||
container.querySelectorAll(".delete-share").forEach(btn => {
|
container.querySelectorAll(".delete-share").forEach(btn => {
|
||||||
btn.addEventListener("click", evt => {
|
btn.addEventListener("click", evt => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
@@ -262,10 +279,7 @@ function loadShareLinksSection() {
|
|||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: new URLSearchParams({ token })
|
body: new URLSearchParams({ token })
|
||||||
})
|
})
|
||||||
.then(res => {
|
.then(res => res.ok ? res.json() : Promise.reject(res))
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then(json => {
|
.then(json => {
|
||||||
if (json.success) {
|
if (json.success) {
|
||||||
showToast(t("share_deleted_successfully"));
|
showToast(t("share_deleted_successfully"));
|
||||||
@@ -287,12 +301,10 @@ function loadShareLinksSection() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function openAdminPanel() {
|
export function openAdminPanel() {
|
||||||
fetch("/api/admin/getConfig.php", { credentials: "include" })
|
fetch("/api/admin/getConfig.php", { credentials: "include" })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(config => {
|
.then(config => {
|
||||||
// apply header title + globals
|
|
||||||
if (config.header_title) {
|
if (config.header_title) {
|
||||||
document.querySelector(".header-title h1").textContent = config.header_title;
|
document.querySelector(".header-title h1").textContent = config.header_title;
|
||||||
window.headerTitle = config.header_title;
|
window.headerTitle = config.header_title;
|
||||||
@@ -327,8 +339,6 @@ export function openAdminPanel() {
|
|||||||
<div class="editor-close-btn" id="closeAdminPanel">×</div>
|
<div class="editor-close-btn" id="closeAdminPanel">×</div>
|
||||||
<h3>${adminTitle}</h3>
|
<h3>${adminTitle}</h3>
|
||||||
<form id="adminPanelForm">
|
<form id="adminPanelForm">
|
||||||
|
|
||||||
<!-- each section: header + content -->
|
|
||||||
${[
|
${[
|
||||||
{ id: "userManagement", label: t("user_management") },
|
{ id: "userManagement", label: t("user_management") },
|
||||||
{ id: "headerSettings", label: t("header_settings") },
|
{ id: "headerSettings", label: t("header_settings") },
|
||||||
@@ -366,13 +376,15 @@ export function openAdminPanel() {
|
|||||||
.addEventListener("click", () => toggleSection(id));
|
.addEventListener("click", () => toggleSection(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Populate each section’s CONTENT:
|
|
||||||
// — User Mgmt —
|
// — User Mgmt —
|
||||||
document.getElementById("userManagementContent").innerHTML = `
|
document.getElementById("userManagementContent").innerHTML = `
|
||||||
<button type="button" id="adminOpenAddUser" class="btn btn-success me-2">${t("add_user")}</button>
|
<button type="button" id="adminOpenAddUser" class="btn btn-success me-2">${t("add_user")}</button>
|
||||||
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger me-2">${t("remove_user")}</button>
|
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger me-2">${t("remove_user")}</button>
|
||||||
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">${t("user_permissions")}</button>
|
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">${tf("folder_access", "Folder Access")}</button>
|
||||||
|
<button type="button" id="adminOpenUserFlags" class="btn btn-secondary">${tf("user_permissions", "User Permissions")}</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
||||||
document.getElementById("adminOpenAddUser")
|
document.getElementById("adminOpenAddUser")
|
||||||
.addEventListener("click", () => {
|
.addEventListener("click", () => {
|
||||||
toggleVisibility("addUserModal", true);
|
toggleVisibility("addUserModal", true);
|
||||||
@@ -452,7 +464,6 @@ export function openAdminPanel() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// If authBypass is checked, clear the other three
|
|
||||||
document.getElementById("authBypass").addEventListener("change", e => {
|
document.getElementById("authBypass").addEventListener("change", e => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"]
|
["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"]
|
||||||
@@ -460,6 +471,27 @@ export function openAdminPanel() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// after you set #userManagementContent.innerHTML (right after those three buttons are inserted)
|
||||||
|
const userMgmt = document.getElementById("userManagementContent");
|
||||||
|
|
||||||
|
// defensive: remove any old listener first
|
||||||
|
userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick);
|
||||||
|
|
||||||
|
window.__userMgmtDelegatedClick = (e) => {
|
||||||
|
const flagsBtn = e.target.closest("#adminOpenUserFlags");
|
||||||
|
if (flagsBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
openUserFlagsModal();
|
||||||
|
}
|
||||||
|
const folderBtn = e.target.closest("#adminOpenUserPermissions");
|
||||||
|
if (folderBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
openUserPermissionsModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
userMgmt?.addEventListener("click", window.__userMgmtDelegatedClick);
|
||||||
|
|
||||||
// Initialize inputs from config + capture
|
// Initialize inputs from config + capture
|
||||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||||
@@ -473,7 +505,6 @@ export function openAdminPanel() {
|
|||||||
} else {
|
} else {
|
||||||
// modal already exists → just refresh values & re-show
|
// modal already exists → just refresh values & re-show
|
||||||
mdl.style.display = "flex";
|
mdl.style.display = "flex";
|
||||||
// update dark/light as above...
|
|
||||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||||
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
||||||
@@ -527,9 +558,7 @@ function handleSave() {
|
|||||||
enableWebDAV: eWD,
|
enableWebDAV: eWD,
|
||||||
sharedMaxUploadSize: sMax,
|
sharedMaxUploadSize: sMax,
|
||||||
globalOtpauthUrl: gURL
|
globalOtpauthUrl: gURL
|
||||||
}, {
|
}, { "X-CSRF-Token": window.csrfToken })
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
})
|
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
showToast(t("settings_updated_successfully"), "success");
|
showToast(t("settings_updated_successfully"), "success");
|
||||||
@@ -550,7 +579,223 @@ export async function closeAdminPanel() {
|
|||||||
document.getElementById("adminPanelModal").style.display = "none";
|
document.getElementById("adminPanelModal").style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- New: User Permissions Modal ---
|
/* ===========================
|
||||||
|
New: Folder Access (ACL) UI
|
||||||
|
=========================== */
|
||||||
|
|
||||||
|
let __allFoldersCache = null; // array of folder strings
|
||||||
|
async function getAllFolders() {
|
||||||
|
if (__allFoldersCache) return __allFoldersCache.slice();
|
||||||
|
const res = await fetch('/api/folder/getFolderList.php', { credentials: 'include' });
|
||||||
|
const data = await safeJson(res).catch(() => []);
|
||||||
|
const list = Array.isArray(data)
|
||||||
|
? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
// Keep "root" first, hide special internal ones
|
||||||
|
const hidden = new Set(["profile_pics", "trash"]);
|
||||||
|
const cleaned = list
|
||||||
|
.filter(f => f && !hidden.has(f.toLowerCase()))
|
||||||
|
.sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b)));
|
||||||
|
__allFoldersCache = cleaned;
|
||||||
|
return cleaned.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserGrants(username) {
|
||||||
|
const res = await fetch(`/api/admin/acl/getGrants.php?user=${encodeURIComponent(username)}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await safeJson(res).catch(() => ({}));
|
||||||
|
// expected: { grants: { "folder/name": {view,upload,manage,share}, ... } }
|
||||||
|
return (data && data.grants) ? data.grants : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFolderGrantsUI(username, container, folders, grants) {
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
// toolbar
|
||||||
|
const toolbar = document.createElement('div');
|
||||||
|
toolbar.className = 'folder-access-toolbar';
|
||||||
|
toolbar.innerHTML = `
|
||||||
|
<input type="text" class="form-control" style="max-width:220px;" placeholder="${tf('search_folders', 'Search folders')}" />
|
||||||
|
<label class="muted"><input type="checkbox" data-bulk="view" /> ${tf('view_all','View (all)')}</label>
|
||||||
|
<label class="muted"><input type="checkbox" data-bulk="viewOwn" /> ${tf('view_own','View (own)')}</label>
|
||||||
|
<label class="muted"><input type="checkbox" data-bulk="upload" /> ${tf('upload','Upload')}</label>
|
||||||
|
<label class="muted"><input type="checkbox" data-bulk="manage" /> ${tf('manage','Manage')}</label>
|
||||||
|
<label class="muted"><input type="checkbox" data-bulk="share" /> ${tf('share','Share')}</label>
|
||||||
|
<span class="muted">(${tf('applies_to_filtered','applies to filtered list')})</span>
|
||||||
|
`;
|
||||||
|
container.appendChild(toolbar);
|
||||||
|
|
||||||
|
// list (will contain sticky header + rows)
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'folder-access-list';
|
||||||
|
container.appendChild(list);
|
||||||
|
|
||||||
|
const headerHtml = `
|
||||||
|
<div class="folder-access-header">
|
||||||
|
<div>${tf('folder', 'Folder')}</div>
|
||||||
|
<div class="perm-col">${tf('view_all','View (all)')}</div>
|
||||||
|
<div class="perm-col">${tf('view_own','View (own)')}</div>
|
||||||
|
<div class="perm-col">${tf('upload','Upload')}</div>
|
||||||
|
<div class="perm-col">${tf('manage','Manage')}</div>
|
||||||
|
<div class="perm-col">${tf('share','Share')}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
function rowHtml(folder) {
|
||||||
|
const g = grants[folder] || {};
|
||||||
|
const name = folder === 'root' ? '(Root)' : folder;
|
||||||
|
return `
|
||||||
|
<div class="folder-access-row" data-folder="${folder}">
|
||||||
|
<div class="folder-badge"><i class="material-icons" style="font-size:18px;">folder</i>${name}</div>
|
||||||
|
<div class="perm-col"><input type="checkbox" data-cap="view" ${g.view ? 'checked' : ''}></div>
|
||||||
|
<div class="perm-col"><input type="checkbox" data-cap="viewOwn" ${g.viewOwn ? 'checked' : ''}></div>
|
||||||
|
<div class="perm-col"><input type="checkbox" data-cap="upload" ${g.upload ? 'checked' : ''}></div>
|
||||||
|
<div class="perm-col"><input type="checkbox" data-cap="manage" ${g.manage ? 'checked' : ''}></div>
|
||||||
|
<div class="perm-col"><input type="checkbox" data-cap="share" ${g.share ? 'checked' : ''}></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
function applyDeps(row) {
|
||||||
|
const cbView = row.querySelector('input[data-cap="view"]');
|
||||||
|
const cbViewOwn = row.querySelector('input[data-cap="viewOwn"]');
|
||||||
|
const cbUpload = row.querySelector('input[data-cap="upload"]');
|
||||||
|
const cbManage = row.querySelector('input[data-cap="manage"]');
|
||||||
|
const cbShare = row.querySelector('input[data-cap="share"]');
|
||||||
|
|
||||||
|
// Manage ⇒ full view + upload + share
|
||||||
|
if (cbManage.checked) {
|
||||||
|
cbView.checked = true;
|
||||||
|
cbUpload.checked = true;
|
||||||
|
cbShare.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share ⇒ full view
|
||||||
|
if (cbShare.checked) cbView.checked = true;
|
||||||
|
|
||||||
|
// Upload ⇒ at least own view
|
||||||
|
if (cbUpload.checked && !cbView.checked && !cbViewOwn.checked) {
|
||||||
|
cbViewOwn.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full view supersedes own-only
|
||||||
|
if (cbView.checked || cbManage.checked) {
|
||||||
|
cbViewOwn.checked = false;
|
||||||
|
cbViewOwn.disabled = true;
|
||||||
|
cbViewOwn.title = tf('full_view_supersedes_own','Full view supersedes own-only');
|
||||||
|
} else {
|
||||||
|
cbViewOwn.disabled = false;
|
||||||
|
cbViewOwn.removeAttribute('title');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owners can always share (UI hint only)
|
||||||
|
if (cbManage.checked) {
|
||||||
|
cbShare.disabled = true;
|
||||||
|
cbShare.title = tf('owners_can_always_share', 'Owners can always share');
|
||||||
|
} else {
|
||||||
|
cbShare.disabled = false;
|
||||||
|
cbShare.removeAttribute('title');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireRow(row) {
|
||||||
|
const cbView = row.querySelector('input[data-cap="view"]');
|
||||||
|
const cbViewOwn = row.querySelector('input[data-cap="viewOwn"]');
|
||||||
|
const cbUpload = row.querySelector('input[data-cap="upload"]');
|
||||||
|
const cbManage = row.querySelector('input[data-cap="manage"]');
|
||||||
|
const cbShare = row.querySelector('input[data-cap="share"]');
|
||||||
|
|
||||||
|
cbUpload.addEventListener('change', () => applyDeps(row));
|
||||||
|
cbShare .addEventListener('change', () => applyDeps(row));
|
||||||
|
cbManage.addEventListener('change', () => applyDeps(row));
|
||||||
|
|
||||||
|
cbView.addEventListener('change', () => {
|
||||||
|
if (!cbView.checked) { cbManage.checked = false; cbShare.checked = false; }
|
||||||
|
applyDeps(row);
|
||||||
|
});
|
||||||
|
cbViewOwn.addEventListener('change', () => applyDeps(row));
|
||||||
|
|
||||||
|
applyDeps(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(filter = "") {
|
||||||
|
const f = filter.trim().toLowerCase();
|
||||||
|
const rowsHtml = folders
|
||||||
|
.filter(x => !f || x.toLowerCase().includes(f))
|
||||||
|
.map(rowHtml)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
list.innerHTML = headerHtml + rowsHtml;
|
||||||
|
|
||||||
|
list.querySelectorAll('.folder-access-row').forEach(wireRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial render + filter wire-up
|
||||||
|
render();
|
||||||
|
const filterInput = toolbar.querySelector('input[type="text"]');
|
||||||
|
filterInput.addEventListener('input', () => render(filterInput.value));
|
||||||
|
|
||||||
|
// bulk toggles
|
||||||
|
toolbar.querySelectorAll('input[type="checkbox"][data-bulk]').forEach(bulk => {
|
||||||
|
bulk.addEventListener('change', () => {
|
||||||
|
const which = bulk.dataset.bulk;
|
||||||
|
const f = (filterInput.value || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
list.querySelectorAll('.folder-access-row').forEach(row => {
|
||||||
|
const folder = row.dataset.folder || "";
|
||||||
|
if (f && !folder.toLowerCase().includes(f)) return;
|
||||||
|
|
||||||
|
const target = row.querySelector(`input[data-cap="${which}"]`);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
target.checked = bulk.checked;
|
||||||
|
|
||||||
|
// simple implications for bulk; detailed state handled by applyDeps
|
||||||
|
if (which === 'manage' && bulk.checked) {
|
||||||
|
row.querySelector('input[data-cap="view"]').checked = true;
|
||||||
|
row.querySelector('input[data-cap="upload"]').checked = true;
|
||||||
|
row.querySelector('input[data-cap="share"]').checked = true;
|
||||||
|
}
|
||||||
|
if (which === 'share' && bulk.checked) {
|
||||||
|
row.querySelector('input[data-cap="view"]').checked = true;
|
||||||
|
}
|
||||||
|
if (which === 'upload' && bulk.checked) {
|
||||||
|
const v = row.querySelector('input[data-cap="view"]');
|
||||||
|
const vo = row.querySelector('input[data-cap="viewOwn"]');
|
||||||
|
if (!v.checked && !vo.checked) vo.checked = true;
|
||||||
|
}
|
||||||
|
if (which === 'view' && !bulk.checked) {
|
||||||
|
row.querySelector('input[data-cap="manage"]').checked = false;
|
||||||
|
row.querySelector('input[data-cap="share"]').checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDeps(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect grants from a user's UI
|
||||||
|
function collectGrantsFrom(container) {
|
||||||
|
const out = {};
|
||||||
|
container.querySelectorAll('.folder-access-row').forEach(row => {
|
||||||
|
const folder = row.dataset.folder;
|
||||||
|
if (!folder) return;
|
||||||
|
const g = {
|
||||||
|
view: row.querySelector('input[data-cap="view"]').checked,
|
||||||
|
viewOwn: row.querySelector('input[data-cap="viewOwn"]').checked,
|
||||||
|
upload: row.querySelector('input[data-cap="upload"]').checked,
|
||||||
|
manage: row.querySelector('input[data-cap="manage"]').checked,
|
||||||
|
share: row.querySelector('input[data-cap="share"]').checked
|
||||||
|
};
|
||||||
|
if (g.view || g.viewOwn || g.upload || g.manage || g.share) out[folder] = g;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- New: User Permissions (Folder Access) Modal ---
|
||||||
export function openUserPermissionsModal() {
|
export function openUserPermissionsModal() {
|
||||||
let userPermissionsModal = document.getElementById("userPermissionsModal");
|
let userPermissionsModal = document.getElementById("userPermissionsModal");
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||||
@@ -559,8 +804,8 @@ export function openUserPermissionsModal() {
|
|||||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
max-width: 500px;
|
max-width: 780px;
|
||||||
width: 90%;
|
width: 95%;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
`;
|
`;
|
||||||
@@ -570,22 +815,20 @@ export function openUserPermissionsModal() {
|
|||||||
userPermissionsModal.id = "userPermissionsModal";
|
userPermissionsModal.id = "userPermissionsModal";
|
||||||
userPermissionsModal.style.cssText = `
|
userPermissionsModal.style.cssText = `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0; left: 0; width: 100vw; height: 100vh;
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background-color: ${overlayBackground};
|
background-color: ${overlayBackground};
|
||||||
display: flex;
|
display: flex; justify-content: center; align-items: center;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 3500;
|
z-index: 3500;
|
||||||
`;
|
`;
|
||||||
userPermissionsModal.innerHTML = `
|
userPermissionsModal.innerHTML = `
|
||||||
<div class="modal-content" style="${modalContentStyles}">
|
<div class="modal-content" style="${modalContentStyles}">
|
||||||
<span id="closeUserPermissionsModal" class="editor-close-btn">×</span>
|
<span id="closeUserPermissionsModal" class="editor-close-btn">×</span>
|
||||||
<h3>${t("user_permissions")}</h3>
|
<h3>${tf("folder_access", "Folder Access")}</h3>
|
||||||
<div id="userPermissionsList" style="max-height: 300px; overflow-y: auto; margin-bottom: 15px;">
|
<div class="muted" style="margin:-4px 0 10px;">
|
||||||
<!-- User rows will be loaded here -->
|
${tf("grant_folders_help", "Grant per-folder capabilities to each user. 'Upload/Manage/Share' imply 'View'.")}
|
||||||
|
</div>
|
||||||
|
<div id="userPermissionsList" style="max-height: 60vh; overflow-y: auto; margin-bottom: 15px;">
|
||||||
|
<!-- User rows will load here -->
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; justify-content: flex-end; gap: 10px;">
|
<div style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||||
<button type="button" id="cancelUserPermissionsBtn" class="btn btn-secondary">${t("cancel")}</button>
|
<button type="button" id="cancelUserPermissionsBtn" class="btn btn-secondary">${t("cancel")}</button>
|
||||||
@@ -600,107 +843,260 @@ export function openUserPermissionsModal() {
|
|||||||
document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => {
|
document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => {
|
||||||
userPermissionsModal.style.display = "none";
|
userPermissionsModal.style.display = "none";
|
||||||
});
|
});
|
||||||
document.getElementById("saveUserPermissionsBtn").addEventListener("click", () => {
|
document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => {
|
||||||
// Collect permissions data from each user row.
|
// Collect grants for every expanded user (or all rows that have a grants list)
|
||||||
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
|
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
|
||||||
const permissionsData = [];
|
let saves = [];
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
const username = row.getAttribute("data-username");
|
const username = row.getAttribute("data-username");
|
||||||
const folderOnlyCheckbox = row.querySelector("input[data-permission='folderOnly']");
|
const grantsBox = row.querySelector(".folder-grants-box");
|
||||||
const readOnlyCheckbox = row.querySelector("input[data-permission='readOnly']");
|
if (!username || !grantsBox) return;
|
||||||
const disableUploadCheckbox = row.querySelector("input[data-permission='disableUpload']");
|
const grants = collectGrantsFrom(grantsBox);
|
||||||
permissionsData.push({
|
saves.push({ user: username, grants });
|
||||||
username,
|
|
||||||
folderOnly: folderOnlyCheckbox.checked,
|
|
||||||
readOnly: readOnlyCheckbox.checked,
|
|
||||||
disableUpload: disableUploadCheckbox.checked
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
// Send the permissionsData to the server.
|
try {
|
||||||
sendRequest("/api/updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken })
|
if (saves.length === 0) {
|
||||||
.then(response => {
|
showToast(tf("nothing_to_save", "Nothing to save"));
|
||||||
if (response.success) {
|
return;
|
||||||
showToast(t("user_permissions_updated_successfully"));
|
}
|
||||||
userPermissionsModal.style.display = "none";
|
for (const payload of saves) {
|
||||||
} else {
|
await sendRequest("/api/admin/acl/saveGrants.php", "POST", payload, { "X-CSRF-Token": window.csrfToken });
|
||||||
showToast(t("error_updating_permissions") + ": " + (response.error || t("unknown_error")));
|
}
|
||||||
|
showToast(tf("user_permissions_updated_successfully", "User permissions updated successfully"));
|
||||||
|
userPermissionsModal.style.display = "none";
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast(tf("error_updating_permissions", "Error updating permissions"), "error");
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
showToast(t("error_updating_permissions"));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
userPermissionsModal.style.display = "flex";
|
userPermissionsModal.style.display = "flex";
|
||||||
}
|
}
|
||||||
// Load the list of users into the modal.
|
|
||||||
loadUserPermissionsList();
|
loadUserPermissionsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadUserPermissionsList() {
|
async function fetchAllUsers() {
|
||||||
|
const r = await fetch("/api/getUsers.php", { credentials: "include" });
|
||||||
|
return await r.json(); // array of { username, role }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a map of { username: { readOnly, folderOnly, disableUpload, canShare, bypassOwnership } }
|
||||||
|
async function fetchAllUserFlags() {
|
||||||
|
const r = await fetch("/api/getUserPermissions.php", { credentials: "include" });
|
||||||
|
const data = await r.json();
|
||||||
|
// remove deprecated flag if present, so UI never shows it
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
const map = data.allPermissions || data.permissions || data;
|
||||||
|
if (map && typeof map === "object") {
|
||||||
|
Object.values(map).forEach(u => { if (u && typeof u === "object") delete u.folderOnly; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Accept both shapes: {users:[...]} or a plain object map
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
// unlikely, but normalize
|
||||||
|
const out = {};
|
||||||
|
data.forEach(u => { if (u.username) out[u.username] = u; });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (data && data.allPermissions) return data.allPermissions;
|
||||||
|
if (data && data.permissions) return data.permissions;
|
||||||
|
return data || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function flagRow(u, flags) {
|
||||||
|
const f = flags[u.username] || {};
|
||||||
|
const isAdmin = String(u.role) === "1" || u.username.toLowerCase() === "admin";
|
||||||
|
if (isAdmin) return ""; // skip admins here
|
||||||
|
return `
|
||||||
|
<tr data-username="${u.username}">
|
||||||
|
<td><strong>${u.username}</strong></td>
|
||||||
|
<td style="text-align:center;"><input type="checkbox" data-flag="readOnly" ${f.readOnly ? "checked":""}></td>
|
||||||
|
<td style="text-align:center;"><input type="checkbox" data-flag="disableUpload" ${f.disableUpload ? "checked":""}></td>
|
||||||
|
<td style="text-align:center;"><input type="checkbox" data-flag="canShare" ${f.canShare ? "checked":""}></td>
|
||||||
|
<td style="text-align:center;"><input type="checkbox" data-flag="bypassOwnership" ${f.bypassOwnership ? "checked":""}></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openUserFlagsModal() {
|
||||||
|
let modal = document.getElementById("userFlagsModal");
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement("div");
|
||||||
|
modal.id = "userFlagsModal";
|
||||||
|
modal.style.cssText = `
|
||||||
|
position:fixed; inset:0; background:rgba(0,0,0,.5);
|
||||||
|
display:flex; align-items:center; justify-content:center; z-index:3600;
|
||||||
|
`;
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content" style="background:#fff; color:#000; padding:16px; max-width:900px; width:95%; border-radius:8px; position:relative;">
|
||||||
|
<span id="closeUserFlagsModal" class="editor-close-btn" style="right:8px; top:8px;">×</span>
|
||||||
|
<h3>${tf("user_permissions", "User Permissions")}</h3>
|
||||||
|
<p class="muted" style="margin-top:-6px;">
|
||||||
|
${tf("user_flags_help", "Account-level switches. These are NOT per-folder grants.")}
|
||||||
|
</p>
|
||||||
|
<div id="userFlagsBody" style="max-height:60vh; overflow:auto; margin:8px 0;">
|
||||||
|
${t("loading")}…
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:8px;">
|
||||||
|
<button type="button" id="cancelUserFlags" class="btn btn-secondary">${t("cancel")}</button>
|
||||||
|
<button type="button" id="saveUserFlags" class="btn btn-primary">${t("save_permissions")}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
document.getElementById("closeUserFlagsModal").onclick = () => modal.style.display = "none";
|
||||||
|
document.getElementById("cancelUserFlags").onclick = () => modal.style.display = "none";
|
||||||
|
document.getElementById("saveUserFlags").onclick = saveUserFlags;
|
||||||
|
}
|
||||||
|
modal.style.display = "flex";
|
||||||
|
loadUserFlagsList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserFlagsList() {
|
||||||
|
const body = document.getElementById("userFlagsBody");
|
||||||
|
body.textContent = `${t("loading")}…`;
|
||||||
|
try {
|
||||||
|
const users = await fetchAllUsers(); // [{username, role}]
|
||||||
|
const flagsMap = await fetchAllUserFlags(); // { username: {…} }
|
||||||
|
const rows = users.map(u => flagRow(u, flagsMap)).filter(Boolean).join("");
|
||||||
|
body.innerHTML = `
|
||||||
|
<table class="table table-sm" style="width:100%;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>${t("user")}</th>
|
||||||
|
<th>${t("read_only")}</th>
|
||||||
|
<th>${t("disable_upload")}</th>
|
||||||
|
<th>${t("can_share")}</th>
|
||||||
|
<th>bypassOwnership</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows || `<tr><td colspan="6">${t("no_users_found")}</td></tr>`}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
body.innerHTML = `<div class="muted">${t("error_loading_users")}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUserFlags() {
|
||||||
|
const body = document.getElementById("userFlagsBody");
|
||||||
|
const rows = body.querySelectorAll("tbody tr[data-username]");
|
||||||
|
const permissions = [];
|
||||||
|
rows.forEach(tr => {
|
||||||
|
const username = tr.getAttribute("data-username");
|
||||||
|
const get = k => tr.querySelector(`input[data-flag="${k}"]`).checked;
|
||||||
|
permissions.push({
|
||||||
|
username,
|
||||||
|
readOnly: get("readOnly"),
|
||||||
|
disableUpload: get("disableUpload"),
|
||||||
|
canShare: get("canShare"),
|
||||||
|
bypassOwnership: get("bypassOwnership")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// reuse your existing endpoint
|
||||||
|
const res = await sendRequest("/api/updateUserPermissions.php", "PUT",
|
||||||
|
{ permissions },
|
||||||
|
{ "X-CSRF-Token": window.csrfToken }
|
||||||
|
);
|
||||||
|
if (res && res.success) {
|
||||||
|
showToast(tf("user_permissions_updated_successfully", "User permissions updated successfully"));
|
||||||
|
document.getElementById("userFlagsModal").style.display = "none";
|
||||||
|
} else {
|
||||||
|
showToast(tf("error_updating_permissions", "Error updating permissions"), "error");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast(tf("error_updating_permissions", "Error updating permissions"), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserPermissionsList() {
|
||||||
const listContainer = document.getElementById("userPermissionsList");
|
const listContainer = document.getElementById("userPermissionsList");
|
||||||
if (!listContainer) return;
|
if (!listContainer) return;
|
||||||
listContainer.innerHTML = "";
|
listContainer.innerHTML = `<p>${t("loading")}…</p>`;
|
||||||
|
|
||||||
// First, fetch the current permissions from the server.
|
try {
|
||||||
fetch("/api/getUserPermissions.php", { credentials: "include" })
|
const usersRes = await fetch("/api/getUsers.php", { credentials: "include" });
|
||||||
.then(response => response.json())
|
const usersData = await safeJson(usersRes);
|
||||||
.then(permissionsData => {
|
|
||||||
// Then, fetch the list of users.
|
|
||||||
return fetch("/api/getUsers.php", { credentials: "include" })
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(usersData => {
|
|
||||||
const users = Array.isArray(usersData) ? usersData : (usersData.users || []);
|
const users = Array.isArray(usersData) ? usersData : (usersData.users || []);
|
||||||
if (users.length === 0) {
|
if (!users.length) {
|
||||||
listContainer.innerHTML = "<p>" + t("no_users_found") + "</p>";
|
listContainer.innerHTML = "<p>" + t("no_users_found") + "</p>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preload folders once (admin should see all)
|
||||||
|
const folders = await getAllFolders();
|
||||||
|
|
||||||
|
listContainer.innerHTML = ""; // clear
|
||||||
users.forEach(user => {
|
users.forEach(user => {
|
||||||
// Skip admin users.
|
// Skip admins
|
||||||
if ((user.role && user.role === "1") || user.username.toLowerCase() === "admin") return;
|
if ((user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin") return;
|
||||||
|
|
||||||
// Use stored permissions if available; otherwise fall back to defaults.
|
|
||||||
const defaultPerm = {
|
|
||||||
folderOnly: false,
|
|
||||||
readOnly: false,
|
|
||||||
disableUpload: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Normalize the username key to match server storage (e.g., lowercase)
|
|
||||||
const usernameKey = user.username.toLowerCase();
|
|
||||||
|
|
||||||
const userPerm = (permissionsData && typeof permissionsData === "object" && (usernameKey in permissionsData))
|
|
||||||
? permissionsData[usernameKey]
|
|
||||||
: defaultPerm;
|
|
||||||
|
|
||||||
// Create a row for the user.
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.classList.add("user-permission-row");
|
row.classList.add("user-permission-row");
|
||||||
row.setAttribute("data-username", user.username);
|
row.setAttribute("data-username", user.username);
|
||||||
row.style.padding = "10px 0";
|
row.style.padding = "6px 0";
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div style="font-weight: bold; margin-bottom: 5px;">${user.username}</div>
|
<div class="user-perm-header"
|
||||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
role="button"
|
||||||
<label style="display: flex; align-items: center; gap: 5px;">
|
tabindex="0"
|
||||||
<input type="checkbox" data-permission="folderOnly" ${userPerm.folderOnly ? "checked" : ""} />
|
aria-expanded="false"
|
||||||
${t("user_folder_only")}
|
style="display:flex;align-items:center;justify-content:space-between;
|
||||||
</label>
|
padding:8px 6px;border-radius:6px;cursor:pointer;
|
||||||
<label style="display: flex; align-items: center; gap: 5px;">
|
background:var(--perm-header-bg, rgba(0,0,0,0.04));">
|
||||||
<input type="checkbox" data-permission="readOnly" ${userPerm.readOnly ? "checked" : ""} />
|
<span style="font-weight:600;">${user.username}</span>
|
||||||
${t("read_only")}
|
<i class="material-icons perm-caret" style="transition:transform .2s; transform:rotate(-90deg);">expand_more</i>
|
||||||
</label>
|
|
||||||
<label style="display: flex; align-items: center; gap: 5px;">
|
|
||||||
<input type="checkbox" data-permission="disableUpload" ${userPerm.disableUpload ? "checked" : ""} />
|
|
||||||
${t("disable_upload")}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<hr style="margin-top: 10px; border: 0; border-bottom: 1px solid #ccc;">
|
|
||||||
|
<div class="user-perm-details" style="display:none;margin:8px 4px 2px 10px;">
|
||||||
|
<div class="folder-grants-box">
|
||||||
|
<div class="muted">${t("loading")}…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin:8px 0 4px;border:0;border-bottom:1px solid #ccc;">
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const header = row.querySelector(".user-perm-header");
|
||||||
|
const details = row.querySelector(".user-perm-details");
|
||||||
|
const caret = row.querySelector(".perm-caret");
|
||||||
|
const grantsBox = row.querySelector(".folder-grants-box");
|
||||||
|
|
||||||
|
async function ensureLoaded() {
|
||||||
|
if (grantsBox.dataset.loaded === "1") return;
|
||||||
|
try {
|
||||||
|
const grants = await getUserGrants(user.username);
|
||||||
|
renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], grants);
|
||||||
|
grantsBox.dataset.loaded = "1";
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
grantsBox.innerHTML = `<div class="muted">${tf("error_loading_user_grants", "Error loading user grants")}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOpen() {
|
||||||
|
const willShow = details.style.display === "none";
|
||||||
|
details.style.display = willShow ? "block" : "none";
|
||||||
|
header.setAttribute("aria-expanded", willShow ? "true" : "false");
|
||||||
|
caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)";
|
||||||
|
if (willShow) ensureLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
header.addEventListener("click", toggleOpen);
|
||||||
|
header.addEventListener("keydown", e => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); }
|
||||||
|
});
|
||||||
|
|
||||||
listContainer.appendChild(row);
|
listContainer.appendChild(row);
|
||||||
});
|
});
|
||||||
});
|
} catch (err) {
|
||||||
})
|
console.error(err);
|
||||||
.catch(() => {
|
|
||||||
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
|
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
@@ -36,13 +36,33 @@ window.currentOIDCConfig = currentOIDCConfig;
|
|||||||
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
|
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
|
||||||
|
|
||||||
// override showToast to suppress the "Please log in to continue." toast during TOTP
|
// override showToast to suppress the "Please log in to continue." toast during TOTP
|
||||||
function showToast(msgKey) {
|
|
||||||
const msg = t(msgKey);
|
function showToast(msgKeyOrText, type) {
|
||||||
if (window.pendingTOTP && msgKey === "please_log_in_to_continue") {
|
const isDemoHost = window.location.hostname.toLowerCase() === "demo.filerise.net";
|
||||||
|
|
||||||
|
// If it's the pre-login prompt and we're on the demo site, show demo creds instead.
|
||||||
|
if (isDemoHost) {
|
||||||
|
return originalShowToast("Demo site — use: \nUsername: demo\nPassword: demo", 12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don’t nag during pending TOTP, as you already had
|
||||||
|
if (window.pendingTOTP && msgKeyOrText === "please_log_in_to_continue") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
originalShowToast(msg);
|
|
||||||
|
// Translate if a key; otherwise pass through the raw text
|
||||||
|
let msg = msgKeyOrText;
|
||||||
|
try {
|
||||||
|
const translated = t(msgKeyOrText);
|
||||||
|
// If t() changed it or it's a key-like string, use the translation
|
||||||
|
if (typeof translated === "string" && translated !== msgKeyOrText) {
|
||||||
|
msg = translated;
|
||||||
}
|
}
|
||||||
|
} catch { /* if t() isn’t available here, just use the original */ }
|
||||||
|
|
||||||
|
return originalShowToast(msg);
|
||||||
|
}
|
||||||
|
|
||||||
window.showToast = showToast;
|
window.showToast = showToast;
|
||||||
|
|
||||||
const originalFetch = window.fetch;
|
const originalFetch = window.fetch;
|
||||||
@@ -161,27 +181,31 @@ function updateLoginOptionsUIFromStorage() {
|
|||||||
|
|
||||||
export function loadAdminConfigFunc() {
|
export function loadAdminConfigFunc() {
|
||||||
return fetch("/api/admin/getConfig.php", { credentials: "include" })
|
return fetch("/api/admin/getConfig.php", { credentials: "include" })
|
||||||
.then(response => response.json())
|
.then(async (response) => {
|
||||||
.then(config => {
|
// If a proxy or some edge returns 204/empty, handle gracefully
|
||||||
localStorage.setItem("headerTitle", config.header_title || "FileRise");
|
let config = {};
|
||||||
|
try { config = await response.json(); } catch { config = {}; }
|
||||||
|
|
||||||
// Update login options using the nested loginOptions object.
|
const headerTitle = config.header_title || "FileRise";
|
||||||
localStorage.setItem("disableFormLogin", config.loginOptions.disableFormLogin);
|
localStorage.setItem("headerTitle", headerTitle);
|
||||||
localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth);
|
|
||||||
localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin);
|
document.title = headerTitle;
|
||||||
|
const lo = config.loginOptions || {};
|
||||||
|
localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin));
|
||||||
|
localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth));
|
||||||
|
localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin));
|
||||||
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
|
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
|
||||||
localStorage.setItem("authBypass", String(!!config.loginOptions.authBypass));
|
// These may be absent for non-admins; default them
|
||||||
localStorage.setItem("authHeaderName", config.loginOptions.authHeaderName || "X-Remote-User");
|
localStorage.setItem("authBypass", String(!!lo.authBypass));
|
||||||
|
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
|
||||||
|
|
||||||
updateLoginOptionsUIFromStorage();
|
updateLoginOptionsUIFromStorage();
|
||||||
|
|
||||||
const headerTitleElem = document.querySelector(".header-title h1");
|
const headerTitleElem = document.querySelector(".header-title h1");
|
||||||
if (headerTitleElem) {
|
if (headerTitleElem) headerTitleElem.textContent = headerTitle;
|
||||||
headerTitleElem.textContent = config.header_title || "FileRise";
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Use defaults.
|
// Fallback defaults if request truly fails
|
||||||
localStorage.setItem("headerTitle", "FileRise");
|
localStorage.setItem("headerTitle", "FileRise");
|
||||||
localStorage.setItem("disableFormLogin", "false");
|
localStorage.setItem("disableFormLogin", "false");
|
||||||
localStorage.setItem("disableBasicAuth", "false");
|
localStorage.setItem("disableBasicAuth", "false");
|
||||||
@@ -190,9 +214,7 @@ export function loadAdminConfigFunc() {
|
|||||||
updateLoginOptionsUIFromStorage();
|
updateLoginOptionsUIFromStorage();
|
||||||
|
|
||||||
const headerTitleElem = document.querySelector(".header-title h1");
|
const headerTitleElem = document.querySelector(".header-title h1");
|
||||||
if (headerTitleElem) {
|
if (headerTitleElem) headerTitleElem.textContent = "FileRise";
|
||||||
headerTitleElem.textContent = "FileRise";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,16 +228,25 @@ 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ LOAD MODE FIRST, THEN INSTANTIATE CODEMIRROR
|
||||||
|
ensureModeLoaded(mode).finally(() => {
|
||||||
|
const editor = CodeMirror.fromTextArea(
|
||||||
|
document.getElementById("fileEditor"),
|
||||||
|
cmOptions
|
||||||
|
);
|
||||||
|
|
||||||
window.currentEditor = editor;
|
window.currentEditor = editor;
|
||||||
|
|
||||||
@@ -138,13 +285,17 @@ export function editFile(fileName, folder) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function updateEditorTheme() {
|
function updateEditorTheme() {
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
const isDark = document.body.classList.contains("dark-mode");
|
||||||
editor.setOption("theme", isDarkMode ? "material-darker" : "default");
|
editor.setOption("theme", isDark ? "material-darker" : "default");
|
||||||
}
|
}
|
||||||
|
const toggle = document.getElementById("darkModeToggle");
|
||||||
document.getElementById("darkModeToggle").addEventListener("click", updateEditorTheme);
|
if (toggle) toggle.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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ import {
|
|||||||
export let fileData = [];
|
export let fileData = [];
|
||||||
export let sortOrder = { column: "uploaded", ascending: true };
|
export let sortOrder = { column: "uploaded", ascending: true };
|
||||||
|
|
||||||
|
// Hide "Edit" for files >10 MiB
|
||||||
|
const MAX_EDIT_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Latest-response-wins guard (prevents double render/flicker if loadFileList gets called twice)
|
||||||
|
let __fileListReqSeq = 0;
|
||||||
|
|
||||||
window.itemsPerPage = parseInt(
|
window.itemsPerPage = parseInt(
|
||||||
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
|
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
|
||||||
10
|
10
|
||||||
@@ -45,9 +51,40 @@ window.viewMode = localStorage.getItem("viewMode") || "table";
|
|||||||
// Global flag for advanced search mode.
|
// Global flag for advanced search mode.
|
||||||
window.advancedSearchEnabled = false;
|
window.advancedSearchEnabled = false;
|
||||||
|
|
||||||
/**
|
/* ===========================================================
|
||||||
* --- Helper Functions ---
|
SECURITY: build file URLs only via the API (no /uploads)
|
||||||
*/
|
=========================================================== */
|
||||||
|
function apiFileUrl(folder, name, inline = false) {
|
||||||
|
const f = folder && folder !== "root" ? folder : "root";
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
folder: f,
|
||||||
|
file: name,
|
||||||
|
inline: inline ? "1" : "0",
|
||||||
|
t: String(Date.now()) // cache-bust
|
||||||
|
});
|
||||||
|
return `/api/file/download.php?${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
Helper: robust JSON handling
|
||||||
|
----------------------------- */
|
||||||
|
// Parse JSON if possible; throw on non-2xx with useful message & 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 ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes.
|
* Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes.
|
||||||
@@ -95,8 +132,7 @@ function buildFolderSummary(filteredFiles) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --- Advanced Search Toggle ---
|
* Advanced Search toggle
|
||||||
* Toggles advanced search mode. When enabled, the search will include additional keys (e.g. "content").
|
|
||||||
*/
|
*/
|
||||||
function toggleAdvancedSearch() {
|
function toggleAdvancedSearch() {
|
||||||
window.advancedSearchEnabled = !window.advancedSearchEnabled;
|
window.advancedSearchEnabled = !window.advancedSearchEnabled;
|
||||||
@@ -104,27 +140,21 @@ function toggleAdvancedSearch() {
|
|||||||
if (advancedBtn) {
|
if (advancedBtn) {
|
||||||
advancedBtn.textContent = window.advancedSearchEnabled ? "Basic Search" : "Advanced Search";
|
advancedBtn.textContent = window.advancedSearchEnabled ? "Basic Search" : "Advanced Search";
|
||||||
}
|
}
|
||||||
// Re-run the file table rendering with updated search settings.
|
|
||||||
renderFileTable(window.currentFolder);
|
renderFileTable(window.currentFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.imageCache = window.imageCache || {};
|
window.imageCache = window.imageCache || {};
|
||||||
function cacheImage(imgElem, key) {
|
function cacheImage(imgElem, key) {
|
||||||
// Save the current src for future renders.
|
|
||||||
window.imageCache[key] = imgElem.src;
|
window.imageCache[key] = imgElem.src;
|
||||||
}
|
}
|
||||||
window.cacheImage = cacheImage;
|
window.cacheImage = cacheImage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --- Fuse.js Search Helper ---
|
* Fuse.js fuzzy search helper
|
||||||
* Uses Fuse.js to perform a fuzzy search on fileData.
|
|
||||||
* By default, searches over file name, uploader, and tag names.
|
|
||||||
* When advanced search is enabled, it also includes the 'content' property.
|
|
||||||
*/
|
*/
|
||||||
function searchFiles(searchTerm) {
|
function searchFiles(searchTerm) {
|
||||||
if (!searchTerm) return fileData;
|
if (!searchTerm) return fileData;
|
||||||
|
|
||||||
// Define search keys.
|
|
||||||
let keys = [
|
let keys = [
|
||||||
{ name: 'name', weight: 0.1 },
|
{ name: 'name', weight: 0.1 },
|
||||||
{ name: 'uploader', weight: 0.1 },
|
{ name: 'uploader', weight: 0.1 },
|
||||||
@@ -147,7 +177,7 @@ function searchFiles(searchTerm) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --- VIEW MODE TOGGLE BUTTON & Helpers ---
|
* View mode toggle
|
||||||
*/
|
*/
|
||||||
export function createViewToggleButton() {
|
export function createViewToggleButton() {
|
||||||
let toggleBtn = document.getElementById("toggleViewBtn");
|
let toggleBtn = document.getElementById("toggleViewBtn");
|
||||||
@@ -156,7 +186,6 @@ export function createViewToggleButton() {
|
|||||||
toggleBtn.id = "toggleViewBtn";
|
toggleBtn.id = "toggleViewBtn";
|
||||||
toggleBtn.classList.add("btn", "btn-toggleview");
|
toggleBtn.classList.add("btn", "btn-toggleview");
|
||||||
|
|
||||||
// Set initial icon and tooltip based on current view mode.
|
|
||||||
if (window.viewMode === "gallery") {
|
if (window.viewMode === "gallery") {
|
||||||
toggleBtn.innerHTML = '<i class="material-icons">view_list</i>';
|
toggleBtn.innerHTML = '<i class="material-icons">view_list</i>';
|
||||||
toggleBtn.title = t("switch_to_table_view");
|
toggleBtn.title = t("switch_to_table_view");
|
||||||
@@ -165,7 +194,6 @@ export function createViewToggleButton() {
|
|||||||
toggleBtn.title = t("switch_to_gallery_view");
|
toggleBtn.title = t("switch_to_gallery_view");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert the button before the last button in the header.
|
|
||||||
const headerButtons = document.querySelector(".header-buttons");
|
const headerButtons = document.querySelector(".header-buttons");
|
||||||
if (headerButtons && headerButtons.lastElementChild) {
|
if (headerButtons && headerButtons.lastElementChild) {
|
||||||
headerButtons.insertBefore(toggleBtn, headerButtons.lastElementChild);
|
headerButtons.insertBefore(toggleBtn, headerButtons.lastElementChild);
|
||||||
@@ -202,90 +230,77 @@ window.toggleRowSelection = toggleRowSelection;
|
|||||||
window.updateRowHighlight = updateRowHighlight;
|
window.updateRowHighlight = updateRowHighlight;
|
||||||
|
|
||||||
export async function loadFileList(folderParam) {
|
export async function loadFileList(folderParam) {
|
||||||
|
const reqId = ++__fileListReqSeq; // latest call wins
|
||||||
const folder = folderParam || "root";
|
const folder = folderParam || "root";
|
||||||
const fileListContainer = document.getElementById("fileList");
|
const fileListContainer = document.getElementById("fileList");
|
||||||
const actionsContainer = document.getElementById("fileListActions");
|
const actionsContainer = document.getElementById("fileListActions");
|
||||||
|
|
||||||
// 1) show loader
|
// 1) show loader (only this request is allowed to render)
|
||||||
fileListContainer.style.visibility = "hidden";
|
fileListContainer.style.visibility = "hidden";
|
||||||
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
|
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2) fetch files + folders in parallel
|
// Kick off both in parallel, but render as soon as FILES are ready
|
||||||
const [filesRes, foldersRes] = await Promise.all([
|
const filesPromise = fetch(
|
||||||
fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`),
|
`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`,
|
||||||
fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`)
|
{ credentials: 'include' }
|
||||||
]);
|
);
|
||||||
|
const foldersPromise = fetch(
|
||||||
|
`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`,
|
||||||
|
{ credentials: 'include' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ----- FILES FIRST -----
|
||||||
|
const filesRes = await filesPromise;
|
||||||
|
|
||||||
if (filesRes.status === 401) {
|
if (filesRes.status === 401) {
|
||||||
|
// session expired — bounce to logout
|
||||||
window.location.href = "/api/auth/logout.php";
|
window.location.href = "/api/auth/logout.php";
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
const data = await filesRes.json();
|
if (filesRes.status === 403) {
|
||||||
const folderRaw = await foldersRes.json();
|
// forbidden — friendly message, keep UI responsive
|
||||||
|
fileListContainer.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
${t("no_access_to_resource") || "You don't have access to this folder."}
|
||||||
|
</div>`;
|
||||||
|
showToast(t("no_access_to_resource") || "You don't have access to this folder.", "error");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// --- build ONLY the *direct* children of current folder ---
|
const data = await safeJson(filesRes);
|
||||||
let subfolders = [];
|
if (data.error) {
|
||||||
const hidden = new Set(["profile_pics", "trash"]);
|
throw new Error(typeof data.error === 'string' ? data.error : 'Server returned an error.');
|
||||||
if (Array.isArray(folderRaw)) {
|
|
||||||
const allPaths = folderRaw.map(item => item.folder ?? item);
|
|
||||||
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
|
|
||||||
subfolders = allPaths
|
|
||||||
.filter(p => {
|
|
||||||
if (folder === "root") {
|
|
||||||
return p.indexOf("/") === -1;
|
|
||||||
}
|
}
|
||||||
if (!p.startsWith(folder + "/")) return false;
|
|
||||||
return p.split("/").length === depth;
|
// If another loadFileList ran after this one, bail before touching the DOM
|
||||||
})
|
if (reqId !== __fileListReqSeq) return [];
|
||||||
.map(p => ({ name: p.split("/").pop(), full: p }));
|
|
||||||
}
|
|
||||||
subfolders = subfolders.filter(sf => !hidden.has(sf.name));
|
|
||||||
|
|
||||||
// 3) clear loader
|
// 3) clear loader
|
||||||
fileListContainer.innerHTML = "";
|
fileListContainer.innerHTML = "";
|
||||||
|
|
||||||
// 4) handle “no files” case
|
// 4) handle “no files” case
|
||||||
if (!data.files || Object.keys(data.files).length === 0) {
|
if (!data.files || Object.keys(data.files).length === 0) {
|
||||||
fileListContainer.textContent = t("no_files_found");
|
if (reqId !== __fileListReqSeq) return [];
|
||||||
|
fileListContainer.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
${t("no_files_found")}
|
||||||
|
<div style="margin-top:6px;font-size:.9em;color:#777">
|
||||||
|
${t("no_folder_access_yet") || "No folder access has been assigned to your account yet."}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
// hide summary + slider
|
|
||||||
const summaryElem = document.getElementById("fileSummary");
|
const summaryElem = document.getElementById("fileSummary");
|
||||||
if (summaryElem) summaryElem.style.display = "none";
|
if (summaryElem) summaryElem.style.display = "none";
|
||||||
const sliderContainer = document.getElementById("viewSliderContainer");
|
const sliderContainer = document.getElementById("viewSliderContainer");
|
||||||
if (sliderContainer) sliderContainer.style.display = "none";
|
if (sliderContainer) sliderContainer.style.display = "none";
|
||||||
|
|
||||||
// show/hide folder strip *even when there are no files*
|
const strip = document.getElementById("folderStripContainer");
|
||||||
let strip = document.getElementById("folderStripContainer");
|
if (strip) strip.style.display = "none";
|
||||||
if (!strip) {
|
|
||||||
strip = document.createElement("div");
|
|
||||||
strip.id = "folderStripContainer";
|
|
||||||
strip.className = "folder-strip-container";
|
|
||||||
actionsContainer.parentNode.insertBefore(strip, fileListContainer);
|
|
||||||
}
|
|
||||||
if (window.showFoldersInList && subfolders.length) {
|
|
||||||
strip.innerHTML = subfolders.map(sf => `
|
|
||||||
<div class="folder-item" data-folder="${sf.full}">
|
|
||||||
<i class="material-icons">folder</i>
|
|
||||||
<div class="folder-name">${escapeHTML(sf.name)}</div>
|
|
||||||
</div>
|
|
||||||
`).join("");
|
|
||||||
strip.style.display = "flex";
|
|
||||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
|
||||||
el.addEventListener("click", () => {
|
|
||||||
const dest = el.dataset.folder;
|
|
||||||
window.currentFolder = dest;
|
|
||||||
localStorage.setItem("lastOpenedFolder", dest);
|
|
||||||
updateBreadcrumbTitle(dest);
|
|
||||||
loadFileList(dest);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
strip.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFileActionButtons();
|
updateFileActionButtons();
|
||||||
return [];
|
fileListContainer.style.visibility = "visible";
|
||||||
|
// We still try to populate the folder strip below
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) normalize files array
|
// 5) normalize files array
|
||||||
@@ -295,14 +310,25 @@ export async function loadFileList(folderParam) {
|
|||||||
return meta;
|
return meta;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
data.files = data.files.map(f => {
|
data.files = data.files.map(f => {
|
||||||
f.fullName = (f.path || f.name).trim().toLowerCase();
|
f.fullName = (f.path || f.name).trim().toLowerCase();
|
||||||
f.editable = canEditFile(f.name);
|
|
||||||
|
// Prefer numeric size if API provides it; otherwise parse the "1.2 MB" string
|
||||||
|
let bytes = Number.isFinite(f.sizeBytes)
|
||||||
|
? f.sizeBytes
|
||||||
|
: parseSizeToBytes(String(f.size || ""));
|
||||||
|
|
||||||
|
if (!Number.isFinite(bytes)) bytes = Infinity;
|
||||||
|
|
||||||
|
f.editable = canEditFile(f.name) && (bytes <= MAX_EDIT_BYTES);
|
||||||
f.folder = folder;
|
f.folder = folder;
|
||||||
return f;
|
return f;
|
||||||
});
|
});
|
||||||
fileData = data.files;
|
fileData = data.files;
|
||||||
|
|
||||||
|
if (reqId !== __fileListReqSeq) return [];
|
||||||
|
|
||||||
// 6) inject summary + slider
|
// 6) inject summary + slider
|
||||||
if (actionsContainer) {
|
if (actionsContainer) {
|
||||||
// a) summary
|
// a) summary
|
||||||
@@ -384,7 +410,46 @@ export async function loadFileList(folderParam) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7) inject folder strip below actions, above file list
|
// 7) render files
|
||||||
|
if (reqId !== __fileListReqSeq) return [];
|
||||||
|
|
||||||
|
if (window.viewMode === "gallery") {
|
||||||
|
renderGalleryView(folder);
|
||||||
|
} else {
|
||||||
|
renderFileTable(folder);
|
||||||
|
}
|
||||||
|
updateFileActionButtons();
|
||||||
|
fileListContainer.style.visibility = "visible";
|
||||||
|
|
||||||
|
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
|
||||||
|
try {
|
||||||
|
const foldersRes = await foldersPromise;
|
||||||
|
// If folders API forbids, just skip the strip; keep file rows rendered
|
||||||
|
if (foldersRes.status === 403) {
|
||||||
|
const strip = document.getElementById("folderStripContainer");
|
||||||
|
if (strip) strip.style.display = "none";
|
||||||
|
return data.files;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderRaw = await safeJson(foldersRes).catch(() => []); // don't block file render on strip issues
|
||||||
|
if (reqId !== __fileListReqSeq) return data.files;
|
||||||
|
|
||||||
|
// --- build ONLY the *direct* children of current folder ---
|
||||||
|
let subfolders = [];
|
||||||
|
const hidden = new Set(["profile_pics", "trash"]);
|
||||||
|
if (Array.isArray(folderRaw)) {
|
||||||
|
const allPaths = folderRaw.map(item => item.folder ?? item);
|
||||||
|
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
|
||||||
|
subfolders = allPaths
|
||||||
|
.filter(p => {
|
||||||
|
if (folder === "root") return p.indexOf("/") === -1;
|
||||||
|
if (!p.startsWith(folder + "/")) return false;
|
||||||
|
return p.split("/").length === depth;
|
||||||
|
})
|
||||||
|
.map(p => ({ name: p.split("/").pop(), full: p }));
|
||||||
|
}
|
||||||
|
subfolders = subfolders.filter(sf => !hidden.has(sf.name));
|
||||||
|
|
||||||
let strip = document.getElementById("folderStripContainer");
|
let strip = document.getElementById("folderStripContainer");
|
||||||
if (!strip) {
|
if (!strip) {
|
||||||
strip = document.createElement("div");
|
strip = document.createElement("div");
|
||||||
@@ -402,7 +467,6 @@ export async function loadFileList(folderParam) {
|
|||||||
`).join("");
|
`).join("");
|
||||||
strip.style.display = "flex";
|
strip.style.display = "flex";
|
||||||
|
|
||||||
// wire up each folder‐tile
|
|
||||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||||
// 1) click to navigate
|
// 1) click to navigate
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
@@ -429,11 +493,9 @@ export async function loadFileList(folderParam) {
|
|||||||
window.currentFolder = dest;
|
window.currentFolder = dest;
|
||||||
localStorage.setItem("lastOpenedFolder", dest);
|
localStorage.setItem("lastOpenedFolder", dest);
|
||||||
|
|
||||||
// highlight the strip tile
|
|
||||||
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
|
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
|
||||||
el.classList.add("selected");
|
el.classList.add("selected");
|
||||||
|
|
||||||
// reuse folderManager menu
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
label: t("create_folder"),
|
label: t("create_folder"),
|
||||||
@@ -456,36 +518,37 @@ export async function loadFileList(folderParam) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// one global click to hide any open context menu
|
|
||||||
document.addEventListener("click", hideFolderManagerContextMenu);
|
document.addEventListener("click", hideFolderManagerContextMenu);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
strip.style.display = "none";
|
strip.style.display = "none";
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
// 8) render files
|
// ignore folder errors; rows already rendered
|
||||||
if (window.viewMode === "gallery") {
|
|
||||||
renderGalleryView(folder);
|
|
||||||
} else {
|
|
||||||
renderFileTable(folder);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFileActionButtons();
|
|
||||||
return data.files;
|
return data.files;
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error loading file list:", err);
|
console.error("Error loading file list:", err);
|
||||||
if (err.message !== "Unauthorized") {
|
if (err.status === 403) {
|
||||||
fileListContainer.textContent = "Error loading files.";
|
showToast(t("no_access_to_resource") || "You don't have access to this folder.", "error");
|
||||||
|
const fileListContainer = document.getElementById("fileList");
|
||||||
|
if (fileListContainer) fileListContainer.textContent = t("no_access_to_resource") || "You don't have access to this folder.";
|
||||||
|
} else if (err.message !== "Unauthorized") {
|
||||||
|
const fileListContainer = document.getElementById("fileList");
|
||||||
|
if (fileListContainer) fileListContainer.textContent = "Error loading files.";
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
|
if (reqId === __fileListReqSeq) {
|
||||||
fileListContainer.style.visibility = "visible";
|
fileListContainer.style.visibility = "visible";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update renderFileTable so it writes its content into the provided container.
|
* Render table view
|
||||||
*/
|
*/
|
||||||
export function renderFileTable(folder, container, subfolders) {
|
export function renderFileTable(folder, container, subfolders) {
|
||||||
const fileListContent = container || document.getElementById("fileList");
|
const fileListContent = container || document.getElementById("fileList");
|
||||||
@@ -493,7 +556,6 @@ export function renderFileTable(folder, container, subfolders) {
|
|||||||
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
|
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
|
||||||
let currentPage = window.currentPage || 1;
|
let currentPage = window.currentPage || 1;
|
||||||
|
|
||||||
// Use Fuse.js search via our helper function.
|
|
||||||
const filteredFiles = searchFiles(searchTerm);
|
const filteredFiles = searchFiles(searchTerm);
|
||||||
|
|
||||||
const totalFiles = filteredFiles.length;
|
const totalFiles = filteredFiles.length;
|
||||||
@@ -502,11 +564,11 @@ export function renderFileTable(folder, container, subfolders) {
|
|||||||
currentPage = totalPages > 0 ? totalPages : 1;
|
currentPage = totalPages > 0 ? totalPages : 1;
|
||||||
window.currentPage = currentPage;
|
window.currentPage = currentPage;
|
||||||
}
|
}
|
||||||
const folderPath = folder === "root"
|
|
||||||
? "uploads/"
|
|
||||||
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
|
|
||||||
|
|
||||||
// Build the top controls and append the advanced search toggle button.
|
// We pass a harmless "base" string to keep buildFileTableRow happy,
|
||||||
|
// then we will FIX the preview/thumbnail URLs to the API below.
|
||||||
|
const fakeBase = "#/";
|
||||||
|
|
||||||
const topControlsHTML = buildSearchAndPaginationControls({
|
const topControlsHTML = buildSearchAndPaginationControls({
|
||||||
currentPage,
|
currentPage,
|
||||||
totalPages,
|
totalPages,
|
||||||
@@ -521,7 +583,9 @@ export function renderFileTable(folder, container, subfolders) {
|
|||||||
let rowsHTML = "<tbody>";
|
let rowsHTML = "<tbody>";
|
||||||
if (totalFiles > 0) {
|
if (totalFiles > 0) {
|
||||||
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
|
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
|
||||||
let rowHTML = buildFileTableRow(file, folderPath);
|
// Build row with a neutral base, then correct the links/preview below.
|
||||||
|
let rowHTML = buildFileTableRow(file, fakeBase);
|
||||||
|
// Give the row an ID so we can patch attributes safely
|
||||||
rowHTML = rowHTML.replace("<tr", `<tr id="file-row-${encodeURIComponent(file.name)}-${startIndex + idx}"`);
|
rowHTML = rowHTML.replace("<tr", `<tr id="file-row-${encodeURIComponent(file.name)}-${startIndex + idx}"`);
|
||||||
|
|
||||||
let tagBadgesHTML = "";
|
let tagBadgesHTML = "";
|
||||||
@@ -532,10 +596,9 @@ export function renderFileTable(folder, container, subfolders) {
|
|||||||
});
|
});
|
||||||
tagBadgesHTML += "</div>";
|
tagBadgesHTML += "</div>";
|
||||||
}
|
}
|
||||||
rowHTML = rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
|
rowsHTML += rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
|
||||||
return p1 + p2 + tagBadgesHTML + p3;
|
return p1 + p2 + tagBadgesHTML + p3;
|
||||||
});
|
});
|
||||||
rowsHTML += rowHTML;
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`;
|
rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`;
|
||||||
@@ -545,6 +608,37 @@ export function renderFileTable(folder, container, subfolders) {
|
|||||||
|
|
||||||
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
|
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
|
||||||
|
|
||||||
|
// PATCH each row's preview/thumb to use the secure API URLs
|
||||||
|
if (totalFiles > 0) {
|
||||||
|
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
|
||||||
|
const rowEl = document.getElementById(`file-row-${encodeURIComponent(file.name)}-${startIndex + idx}`);
|
||||||
|
if (!rowEl) return;
|
||||||
|
|
||||||
|
const previewUrl = apiFileUrl(file.folder || folder, file.name, true);
|
||||||
|
|
||||||
|
// Preview button dataset
|
||||||
|
const previewBtn = rowEl.querySelector(".preview-btn");
|
||||||
|
if (previewBtn) {
|
||||||
|
previewBtn.dataset.previewUrl = previewUrl;
|
||||||
|
previewBtn.dataset.previewName = file.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail (if present)
|
||||||
|
const thumbImg = rowEl.querySelector("img");
|
||||||
|
if (thumbImg) {
|
||||||
|
thumbImg.src = previewUrl;
|
||||||
|
thumbImg.setAttribute("data-cache-key", previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any anchor that might have been built to point at a file path
|
||||||
|
rowEl.querySelectorAll('a[href]').forEach(a => {
|
||||||
|
// Only rewrite obvious file anchors (ignore actions with '#', 'javascript:', etc.)
|
||||||
|
if (/^#|^javascript:/i.test(a.getAttribute('href') || '')) return;
|
||||||
|
a.href = previewUrl;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fileListContent.querySelectorAll('.folder-item').forEach(el => {
|
fileListContent.querySelectorAll('.folder-item').forEach(el => {
|
||||||
el.addEventListener('click', () => loadFileList(el.dataset.folder));
|
el.addEventListener('click', () => loadFileList(el.dataset.folder));
|
||||||
});
|
});
|
||||||
@@ -559,14 +653,13 @@ export function renderFileTable(folder, container, subfolders) {
|
|||||||
});
|
});
|
||||||
const nextBtn = document.getElementById("nextPageBtn");
|
const nextBtn = document.getElementById("nextPageBtn");
|
||||||
if (nextBtn) nextBtn.addEventListener("click", () => {
|
if (nextBtn) nextBtn.addEventListener("click", () => {
|
||||||
// totalPages is computed above in this scope
|
|
||||||
if (window.currentPage < totalPages) {
|
if (window.currentPage < totalPages) {
|
||||||
window.currentPage++;
|
window.currentPage++;
|
||||||
renderFileTable(folder, container);
|
renderFileTable(folder, container);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ADD: advanced search toggle
|
// advanced search toggle
|
||||||
const advToggle = document.getElementById("advancedSearchToggle");
|
const advToggle = document.getElementById("advancedSearchToggle");
|
||||||
if (advToggle) advToggle.addEventListener("click", () => {
|
if (advToggle) advToggle.addEventListener("click", () => {
|
||||||
toggleAdvancedSearch();
|
toggleAdvancedSearch();
|
||||||
@@ -581,25 +674,16 @@ export function renderFileTable(folder, container, subfolders) {
|
|||||||
renderFileTable(folder, container);
|
renderFileTable(folder, container);
|
||||||
});
|
});
|
||||||
|
|
||||||
// hook up the master checkbox
|
// Row-select
|
||||||
const selectAll = document.getElementById("selectAll");
|
|
||||||
if (selectAll) {
|
|
||||||
selectAll.addEventListener("change", () => {
|
|
||||||
toggleAllCheckboxes(selectAll);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Row-click selects the row
|
|
||||||
fileListContent.querySelectorAll("tbody tr").forEach(row => {
|
fileListContent.querySelectorAll("tbody tr").forEach(row => {
|
||||||
row.addEventListener("click", e => {
|
row.addEventListener("click", e => {
|
||||||
// grab the underlying checkbox value
|
|
||||||
const cb = row.querySelector(".file-checkbox");
|
const cb = row.querySelector(".file-checkbox");
|
||||||
if (!cb) return;
|
if (!cb) return;
|
||||||
toggleRowSelection(e, cb.value);
|
toggleRowSelection(e, cb.value);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2) Download buttons
|
// Download buttons
|
||||||
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
|
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", e => {
|
btn.addEventListener("click", e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -607,33 +691,36 @@ export function renderFileTable(folder, container, subfolders) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3) Edit buttons
|
// Edit buttons
|
||||||
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", e => {
|
btn.addEventListener("click", async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
editFile(btn.dataset.editName, btn.dataset.editFolder);
|
const m = await import('./fileEditor.js');
|
||||||
|
m.editFile(btn.dataset.editName, btn.dataset.editFolder);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4) Rename buttons
|
// Rename buttons
|
||||||
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", e => {
|
btn.addEventListener("click", async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
const m = await import('./fileActions.js');
|
||||||
|
m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5) Preview buttons
|
// Preview buttons
|
||||||
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
|
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", e => {
|
btn.addEventListener("click", async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
|
const m = await import('./filePreview.js');
|
||||||
|
m.previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
createViewToggleButton();
|
createViewToggleButton();
|
||||||
|
|
||||||
// Setup event listeners.
|
// search input
|
||||||
const newSearchInput = document.getElementById("searchInput");
|
const newSearchInput = document.getElementById("searchInput");
|
||||||
if (newSearchInput) {
|
if (newSearchInput) {
|
||||||
newSearchInput.addEventListener("input", debounce(function () {
|
newSearchInput.addEventListener("input", debounce(function () {
|
||||||
@@ -650,6 +737,7 @@ export function renderFileTable(folder, container, subfolders) {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, 300));
|
}, 300));
|
||||||
}
|
}
|
||||||
|
|
||||||
const slider = document.getElementById('rowHeightSlider');
|
const slider = document.getElementById('rowHeightSlider');
|
||||||
const valueDisplay = document.getElementById('rowHeightValue');
|
const valueDisplay = document.getElementById('rowHeightValue');
|
||||||
if (slider) {
|
if (slider) {
|
||||||
@@ -701,16 +789,16 @@ export function renderFileTable(folder, container, subfolders) {
|
|||||||
// A helper to compute the max image height based on the current column count.
|
// A helper to compute the max image height based on the current column count.
|
||||||
function getMaxImageHeight() {
|
function getMaxImageHeight() {
|
||||||
const columns = parseInt(window.galleryColumns || 3, 10);
|
const columns = parseInt(window.galleryColumns || 3, 10);
|
||||||
return 150 * (7 - columns); // adjust the multiplier as needed.
|
return 150 * (7 - columns);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderGalleryView(folder, container) {
|
export function renderGalleryView(folder, container) {
|
||||||
const fileListContent = container || document.getElementById("fileList");
|
const fileListContent = container || document.getElementById("fileList");
|
||||||
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
||||||
const filteredFiles = searchFiles(searchTerm);
|
const filteredFiles = searchFiles(searchTerm);
|
||||||
const folderPath = folder === "root"
|
|
||||||
? "uploads/"
|
// API preview base (we’ll build per-file URLs)
|
||||||
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
|
const apiBase = `/api/file/download.php?folder=${encodeURIComponent(folder)}&file=`;
|
||||||
|
|
||||||
// pagination settings
|
// pagination settings
|
||||||
const itemsPerPage = window.itemsPerPage;
|
const itemsPerPage = window.itemsPerPage;
|
||||||
@@ -737,7 +825,6 @@ export function renderGalleryView(folder, container) {
|
|||||||
window.currentSearchTerm = searchInput.value;
|
window.currentSearchTerm = searchInput.value;
|
||||||
window.currentPage = 1;
|
window.currentPage = 1;
|
||||||
renderGalleryView(folder);
|
renderGalleryView(folder);
|
||||||
// keep caret at end
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const f = document.getElementById("searchInput");
|
const f = document.getElementById("searchInput");
|
||||||
if (f) {
|
if (f) {
|
||||||
@@ -750,15 +837,12 @@ export function renderGalleryView(folder, container) {
|
|||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// --- Column slider with responsive max ---
|
// determine column max by screen size
|
||||||
const numColumns = window.galleryColumns || 3;
|
const numColumns = window.galleryColumns || 3;
|
||||||
// clamp slider max to 1 on small (<600px), 2 on medium (<900px), else up to 6
|
|
||||||
const w = window.innerWidth;
|
const w = window.innerWidth;
|
||||||
let maxCols = 6;
|
let maxCols = 6;
|
||||||
if (w < 600) maxCols = 1;
|
if (w < 600) maxCols = 1;
|
||||||
else if (w < 900) maxCols = 2;
|
else if (w < 900) maxCols = 2;
|
||||||
|
|
||||||
// ensure current value doesn’t exceed the new max
|
|
||||||
const startCols = Math.min(numColumns, maxCols);
|
const startCols = Math.min(numColumns, maxCols);
|
||||||
window.galleryColumns = startCols;
|
window.galleryColumns = startCols;
|
||||||
|
|
||||||
@@ -777,11 +861,14 @@ export function renderGalleryView(folder, container) {
|
|||||||
|
|
||||||
pageFiles.forEach((file, idx) => {
|
pageFiles.forEach((file, idx) => {
|
||||||
const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx);
|
const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx);
|
||||||
const cacheKey = folderPath + encodeURIComponent(file.name);
|
|
||||||
|
// build preview URL from API (cache-busted)
|
||||||
|
const previewURL = `${apiBase}${encodeURIComponent(file.name)}&t=${Date.now()}`;
|
||||||
|
|
||||||
// thumbnail
|
// thumbnail
|
||||||
let thumbnail;
|
let thumbnail;
|
||||||
if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
|
if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
|
||||||
|
const cacheKey = previewURL; // include folder & file
|
||||||
if (window.imageCache && window.imageCache[cacheKey]) {
|
if (window.imageCache && window.imageCache[cacheKey]) {
|
||||||
thumbnail = `<img
|
thumbnail = `<img
|
||||||
src="${window.imageCache[cacheKey]}"
|
src="${window.imageCache[cacheKey]}"
|
||||||
@@ -790,9 +877,8 @@ export function renderGalleryView(folder, container) {
|
|||||||
alt="${escapeHTML(file.name)}"
|
alt="${escapeHTML(file.name)}"
|
||||||
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
|
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
|
||||||
} else {
|
} else {
|
||||||
const imageUrl = folderPath + encodeURIComponent(file.name) + "?t=" + Date.now();
|
|
||||||
thumbnail = `<img
|
thumbnail = `<img
|
||||||
src="${imageUrl}"
|
src="${previewURL}"
|
||||||
class="gallery-thumbnail"
|
class="gallery-thumbnail"
|
||||||
data-cache-key="${cacheKey}"
|
data-cache-key="${cacheKey}"
|
||||||
alt="${escapeHTML(file.name)}"
|
alt="${escapeHTML(file.name)}"
|
||||||
@@ -834,7 +920,7 @@ export function renderGalleryView(folder, container) {
|
|||||||
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
|
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
|
||||||
|
|
||||||
<div class="gallery-preview" style="cursor:pointer;"
|
<div class="gallery-preview" style="cursor:pointer;"
|
||||||
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
data-preview-url="${previewURL}"
|
||||||
data-preview-name="${file.name}">
|
data-preview-name="${file.name}">
|
||||||
${thumbnail}
|
${thumbnail}
|
||||||
</div>
|
</div>
|
||||||
@@ -846,12 +932,7 @@ export function renderGalleryView(folder, container) {
|
|||||||
</span>
|
</span>
|
||||||
${tagBadgesHTML}
|
${tagBadgesHTML}
|
||||||
|
|
||||||
<div
|
<div class="btn-group btn-group-sm btn-group-hover" role="group" aria-label="File actions" style="margin-top:5px;">
|
||||||
class="btn-group btn-group-sm btn-group-hover"
|
|
||||||
role="group"
|
|
||||||
aria-label="File actions"
|
|
||||||
style="margin-top:5px;"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-success py-1 download-btn"
|
class="btn btn-success py-1 download-btn"
|
||||||
@@ -892,7 +973,6 @@ export function renderGalleryView(folder, container) {
|
|||||||
<i class="material-icons">share</i>
|
<i class="material-icons">share</i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -906,9 +986,7 @@ export function renderGalleryView(folder, container) {
|
|||||||
// render
|
// render
|
||||||
fileListContent.innerHTML = galleryHTML;
|
fileListContent.innerHTML = galleryHTML;
|
||||||
|
|
||||||
// --- Now wire up all behaviors without inline handlers ---
|
// pagination buttons for gallery
|
||||||
|
|
||||||
// ADD: pagination buttons for gallery
|
|
||||||
const prevBtn = document.getElementById("prevPageBtn");
|
const prevBtn = document.getElementById("prevPageBtn");
|
||||||
if (prevBtn) prevBtn.addEventListener("click", () => {
|
if (prevBtn) prevBtn.addEventListener("click", () => {
|
||||||
if (window.currentPage > 1) {
|
if (window.currentPage > 1) {
|
||||||
@@ -924,16 +1002,16 @@ export function renderGalleryView(folder, container) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ←— ADD: advanced search toggle
|
// advanced search toggle
|
||||||
const advToggle = document.getElementById("advancedSearchToggle");
|
const advToggle = document.getElementById("advancedSearchToggle");
|
||||||
if (advToggle) advToggle.addEventListener("click", () => {
|
if (advToggle) advToggle.addEventListener("click", () => {
|
||||||
toggleAdvancedSearch();
|
toggleAdvancedSearch();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ←— ADD: wire up context-menu in gallery
|
// context menu in gallery
|
||||||
bindFileListContextMenu();
|
bindFileListContextMenu();
|
||||||
|
|
||||||
// ADD: items-per-page selector for gallery
|
// items-per-page selector for gallery
|
||||||
const itemsSelect = document.getElementById("itemsPerPageSelect");
|
const itemsSelect = document.getElementById("itemsPerPageSelect");
|
||||||
if (itemsSelect) itemsSelect.addEventListener("change", e => {
|
if (itemsSelect) itemsSelect.addEventListener("change", e => {
|
||||||
window.itemsPerPage = parseInt(e.target.value, 10);
|
window.itemsPerPage = parseInt(e.target.value, 10);
|
||||||
@@ -948,10 +1026,11 @@ export function renderGalleryView(folder, container) {
|
|||||||
img.addEventListener('load', () => cacheImage(img, key));
|
img.addEventListener('load', () => cacheImage(img, key));
|
||||||
});
|
});
|
||||||
|
|
||||||
// preview clicks
|
// preview clicks (dynamic import to avoid global dependency)
|
||||||
fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
|
fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", async () => {
|
||||||
previewFile(el.dataset.previewUrl, el.dataset.previewName);
|
const m = await import('./filePreview.js');
|
||||||
|
m.previewFile(el.dataset.previewUrl, el.dataset.previewName);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -965,17 +1044,19 @@ export function renderGalleryView(folder, container) {
|
|||||||
|
|
||||||
// edit clicks
|
// edit clicks
|
||||||
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", e => {
|
btn.addEventListener("click", async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
editFile(btn.dataset.editName, btn.dataset.editFolder);
|
const m = await import('./fileEditor.js');
|
||||||
|
m.editFile(btn.dataset.editName, btn.dataset.editFolder);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// rename clicks
|
// rename clicks
|
||||||
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", e => {
|
btn.addEventListener("click", async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
const m = await import('./fileActions.js');
|
||||||
|
m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1010,7 +1091,7 @@ export function renderGalleryView(folder, container) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// pagination functions
|
// pagination helpers
|
||||||
window.changePage = newPage => {
|
window.changePage = newPage => {
|
||||||
window.currentPage = newPage;
|
window.currentPage = newPage;
|
||||||
if (window.viewMode === "gallery") renderGalleryView(folder);
|
if (window.viewMode === "gallery") renderGalleryView(folder);
|
||||||
@@ -1025,7 +1106,6 @@ export function renderGalleryView(folder, container) {
|
|||||||
else renderFileTable(folder);
|
else renderFileTable(folder);
|
||||||
};
|
};
|
||||||
|
|
||||||
// update toolbar and toggle button
|
|
||||||
updateFileActionButtons();
|
updateFileActionButtons();
|
||||||
createViewToggleButton();
|
createViewToggleButton();
|
||||||
}
|
}
|
||||||
@@ -1039,18 +1119,16 @@ function updateSliderConstraints() {
|
|||||||
let min = 1;
|
let min = 1;
|
||||||
let max;
|
let max;
|
||||||
|
|
||||||
// Set maximum based on screen size.
|
if (width < 600) {
|
||||||
if (width < 600) { // small devices (phones)
|
|
||||||
max = 1;
|
max = 1;
|
||||||
} else if (width < 1024) { // medium devices
|
} else if (width < 1024) {
|
||||||
max = 3;
|
max = 3;
|
||||||
} else if (width < 1440) { // between medium and large devices
|
} else if (width < 1440) {
|
||||||
max = 4;
|
max = 4;
|
||||||
} else { // large devices and above
|
} else {
|
||||||
max = 6;
|
max = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust the slider's current value if needed
|
|
||||||
let currentVal = parseInt(slider.value, 10);
|
let currentVal = parseInt(slider.value, 10);
|
||||||
if (currentVal > max) {
|
if (currentVal > max) {
|
||||||
currentVal = max;
|
currentVal = max;
|
||||||
@@ -1061,7 +1139,6 @@ function updateSliderConstraints() {
|
|||||||
slider.max = max;
|
slider.max = max;
|
||||||
document.getElementById("galleryColumnsValue").textContent = currentVal;
|
document.getElementById("galleryColumnsValue").textContent = currentVal;
|
||||||
|
|
||||||
// Update the grid layout based on the current slider value.
|
|
||||||
const galleryContainer = document.querySelector(".gallery-container");
|
const galleryContainer = document.querySelector(".gallery-container");
|
||||||
if (galleryContainer) {
|
if (galleryContainer) {
|
||||||
galleryContainer.style.gridTemplateColumns = `repeat(${currentVal}, 1fr)`;
|
galleryContainer.style.gridTemplateColumns = `repeat(${currentVal}, 1fr)`;
|
||||||
@@ -1137,12 +1214,48 @@ function parseCustomDate(dateStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function canEditFile(fileName) {
|
export function canEditFile(fileName) {
|
||||||
|
if (!fileName || typeof fileName !== "string") return false;
|
||||||
|
const dot = fileName.lastIndexOf(".");
|
||||||
|
if (dot < 0) return false;
|
||||||
|
|
||||||
|
const ext = fileName.slice(dot + 1).toLowerCase();
|
||||||
|
|
||||||
const allowedExtensions = [
|
const allowedExtensions = [
|
||||||
"txt", "html", "htm", "css", "js", "json", "xml",
|
"txt", "text", "md", "markdown", "rst",
|
||||||
"md", "py", "ini", "csv", "log", "conf", "config", "bat",
|
"html", "htm", "xhtml", "shtml",
|
||||||
"rtf", "doc", "docx"
|
"css", "scss", "sass", "less",
|
||||||
|
"js", "mjs", "cjs", "jsx",
|
||||||
|
"ts", "tsx",
|
||||||
|
"json", "jsonc", "ndjson",
|
||||||
|
"yml", "yaml", "toml", "xml", "plist",
|
||||||
|
"ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc",
|
||||||
|
"env", "dotenv",
|
||||||
|
"csv", "tsv", "tab",
|
||||||
|
"log",
|
||||||
|
"sh", "bash", "zsh", "ksh", "fish",
|
||||||
|
"bat", "cmd",
|
||||||
|
"ps1", "psm1", "psd1",
|
||||||
|
"py", "pyw",
|
||||||
|
"rb",
|
||||||
|
"pl", "pm",
|
||||||
|
"go",
|
||||||
|
"rs",
|
||||||
|
"java",
|
||||||
|
"kt", "kts",
|
||||||
|
"scala", "sc",
|
||||||
|
"groovy", "gradle",
|
||||||
|
"c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx",
|
||||||
|
"m", "mm",
|
||||||
|
"swift",
|
||||||
|
"cs", "fs", "fsx",
|
||||||
|
"dart",
|
||||||
|
"lua",
|
||||||
|
"r", "rmd",
|
||||||
|
"sql",
|
||||||
|
"vue", "svelte",
|
||||||
|
"twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade"
|
||||||
];
|
];
|
||||||
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
|
|
||||||
return allowedExtensions.includes(ext);
|
return allowedExtensions.includes(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -66,23 +86,29 @@ export 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") {
|
const permissionsData = await safeJson(res);
|
||||||
window.userFolderOnly = true;
|
|
||||||
console.log("checkUserFolderPermission: using localStorage.folderOnly = true");
|
const isFolderOnly =
|
||||||
|
!!(permissionsData &&
|
||||||
|
permissionsData[username] &&
|
||||||
|
permissionsData[username].folderOnly);
|
||||||
|
|
||||||
|
window.userFolderOnly = isFolderOnly;
|
||||||
|
localStorage.setItem("folderOnly", isFolderOnly ? "true" : "false");
|
||||||
|
|
||||||
|
if (isFolderOnly && username) {
|
||||||
localStorage.setItem("lastOpenedFolder", username);
|
localStorage.setItem("lastOpenedFolder", username);
|
||||||
window.currentFolder = username;
|
window.currentFolder = username;
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
}
|
||||||
return fetch("/api/getUserPermissions.php", { credentials: "include" })
|
return isFolderOnly;
|
||||||
.then(response => response.json())
|
} catch (err) {
|
||||||
.then(permissionsData => {
|
console.error("Error fetching user permissions:", err);
|
||||||
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;
|
window.userFolderOnly = false;
|
||||||
localStorage.setItem("folderOnly", "false");
|
localStorage.setItem("folderOnly", "false");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error("Error fetching user permissions:", err);
|
|
||||||
window.userFolderOnly = false;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------
|
/* ----------------------
|
||||||
@@ -273,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);
|
||||||
}
|
}
|
||||||
@@ -307,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}!`);
|
||||||
@@ -338,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("/");
|
||||||
@@ -363,49 +374,52 @@ function renderBreadcrumbFragment(folderPath) {
|
|||||||
|
|
||||||
export 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);
|
||||||
@@ -413,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;
|
||||||
}
|
}
|
||||||
@@ -455,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();
|
||||||
@@ -480,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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -493,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");
|
||||||
@@ -516,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");
|
||||||
@@ -536,9 +550,11 @@ 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) {
|
||||||
@@ -548,8 +564,11 @@ 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);
|
||||||
|
|
||||||
|
const deleteBtn = document.getElementById("deleteFolderBtn");
|
||||||
|
if (deleteBtn) deleteBtn.addEventListener("click", openDeleteFolderModal);
|
||||||
|
|
||||||
export function openRenameFolderModal() {
|
export function openRenameFolderModal() {
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
@@ -558,45 +577,50 @@ export function openRenameFolderModal() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parts = selectedFolder.split("/");
|
const parts = selectedFolder.split("/");
|
||||||
document.getElementById("newRenameFolderName").value = parts[parts.length - 1];
|
|
||||||
document.getElementById("renameFolderModal").style.display = "block";
|
|
||||||
setTimeout(() => {
|
|
||||||
const input = document.getElementById("newRenameFolderName");
|
const input = document.getElementById("newRenameFolderName");
|
||||||
|
const modal = document.getElementById("renameFolderModal");
|
||||||
|
if (!input || !modal) return;
|
||||||
|
input.value = parts[parts.length - 1];
|
||||||
|
modal.style.display = "block";
|
||||||
|
setTimeout(() => {
|
||||||
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) {
|
|
||||||
|
const submitRename = document.getElementById("submitRenameFolder");
|
||||||
|
if (submitRename) {
|
||||||
|
submitRename.addEventListener("click", function (event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
const newNameBasename = document.getElementById("newRenameFolderName").value.trim();
|
const input = document.getElementById("newRenameFolderName");
|
||||||
|
if (!input) return;
|
||||||
|
const newNameBasename = input.value.trim();
|
||||||
if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) {
|
if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) {
|
||||||
showToast("Please enter a valid new folder name.");
|
showToast("Please enter a valid new folder name.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parentPath = getParentFolder(selectedFolder);
|
const parentPath = getParentFolder(selectedFolder);
|
||||||
const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename;
|
const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename;
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
|
||||||
if (!csrfToken) {
|
fetchWithCsrf("/api/folder/renameFolder.php", {
|
||||||
showToast("CSRF token not loaded yet! Please try again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch("/api/folder/renameFolder.php", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ oldFolder: window.currentFolder, newFolder: newFolderFull })
|
body: JSON.stringify({ oldFolder: window.currentFolder, newFolder: newFolderFull })
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(safeJson)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast("Folder renamed successfully!");
|
showToast("Folder renamed successfully!");
|
||||||
@@ -609,10 +633,13 @@ document.getElementById("submitRenameFolder").addEventListener("click", function
|
|||||||
})
|
})
|
||||||
.catch(error => console.error("Error renaming folder:", error))
|
.catch(error => console.error("Error renaming folder:", error))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
document.getElementById("renameFolderModal").style.display = "none";
|
const modal = document.getElementById("renameFolderModal");
|
||||||
document.getElementById("newRenameFolderName").value = "";
|
const input2 = document.getElementById("newRenameFolderName");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
if (input2) input2.value = "";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function openDeleteFolderModal() {
|
export function openDeleteFolderModal() {
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
@@ -620,27 +647,34 @@ export function openDeleteFolderModal() {
|
|||||||
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 confirmDelete = document.getElementById("confirmDeleteFolder");
|
||||||
|
if (confirmDelete) {
|
||||||
|
confirmDelete.addEventListener("click", function () {
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
|
||||||
fetch("/api/folder/deleteFolder.php", {
|
fetchWithCsrf("/api/folder/deleteFolder.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
credentials: "include",
|
||||||
"X-CSRF-Token": csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ folder: selectedFolder })
|
body: JSON.stringify({ folder: selectedFolder })
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(safeJson)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast("Folder deleted successfully!");
|
showToast("Folder deleted successfully!");
|
||||||
@@ -653,22 +687,38 @@ document.getElementById("confirmDeleteFolder").addEventListener("click", functio
|
|||||||
})
|
})
|
||||||
.catch(error => console.error("Error deleting folder:", error))
|
.catch(error => console.error("Error deleting folder:", error))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
document.getElementById("deleteFolderModal").style.display = "none";
|
const modal = document.getElementById("deleteFolderModal");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById("createFolderBtn").addEventListener("click", function () {
|
const createBtn = document.getElementById("createFolderBtn");
|
||||||
document.getElementById("createFolderModal").style.display = "block";
|
if (createBtn) {
|
||||||
document.getElementById("newFolderName").focus();
|
createBtn.addEventListener("click", function () {
|
||||||
|
const modal = document.getElementById("createFolderModal");
|
||||||
|
const input = document.getElementById("newFolderName");
|
||||||
|
if (modal) modal.style.display = "block";
|
||||||
|
if (input) input.focus();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById("cancelCreateFolder").addEventListener("click", function () {
|
const cancelCreate = document.getElementById("cancelCreateFolder");
|
||||||
document.getElementById("createFolderModal").style.display = "none";
|
if (cancelCreate) {
|
||||||
document.getElementById("newFolderName").value = "";
|
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");
|
attachEnterKeyListener("createFolderModal", "submitCreateFolder");
|
||||||
document.getElementById("submitCreateFolder").addEventListener("click", async () => {
|
|
||||||
const folderInput = document.getElementById("newFolderName").value.trim();
|
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.");
|
if (!folderInput) return showToast("Please enter a folder name.");
|
||||||
|
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
@@ -685,23 +735,12 @@ document.getElementById("submitCreateFolder").addEventListener("click", async ()
|
|||||||
fetchWithCsrf("/api/folder/createFolder.php", {
|
fetchWithCsrf("/api/folder/createFolder.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
body: JSON.stringify({ folderName: folderInput, parent })
|
body: JSON.stringify({ folderName: folderInput, parent })
|
||||||
})
|
})
|
||||||
.then(async res => {
|
.then(safeJson)
|
||||||
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);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
if (!data.success) throw new Error(data.error || "Server rejected the request");
|
||||||
showToast("Folder created!");
|
showToast("Folder created!");
|
||||||
const full = parent ? `${parent}/${folderInput}` : folderInput;
|
const full = parent ? `${parent}/${folderInput}` : folderInput;
|
||||||
window.currentFolder = full;
|
window.currentFolder = full;
|
||||||
@@ -712,10 +751,13 @@ document.getElementById("submitCreateFolder").addEventListener("click", async ()
|
|||||||
showToast("Error creating folder: " + e.message);
|
showToast("Error creating folder: " + e.message);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
document.getElementById("createFolderModal").style.display = "none";
|
const modal = document.getElementById("createFolderModal");
|
||||||
document.getElementById("newFolderName").value = "";
|
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 ----------
|
||||||
export function showFolderManagerContextMenu(x, y, menuItems) {
|
export function showFolderManagerContextMenu(x, y, menuItems) {
|
||||||
@@ -773,21 +815,28 @@ export 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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -806,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 () {
|
||||||
@@ -825,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) {
|
||||||
@@ -847,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 {
|
||||||
@@ -855,4 +920,5 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initial context menu delegation bind
|
||||||
bindFolderManagerContextMenu();
|
bindFolderManagerContextMenu();
|
||||||
@@ -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,14 +12,106 @@ 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);
|
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
||||||
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
||||||
|
|
||||||
window.currentFolder = "root";
|
window.currentFolder = "root";
|
||||||
initTagSearch();
|
|
||||||
loadFileList(window.currentFolder);
|
|
||||||
const stored = localStorage.getItem('showFoldersInList');
|
const stored = localStorage.getItem('showFoldersInList');
|
||||||
window.showFoldersInList = stored === null ? true : stored === 'true';
|
window.showFoldersInList = stored === null ? true : stored === 'true';
|
||||||
|
loadAdminConfigFunc();
|
||||||
|
initTagSearch();
|
||||||
|
loadFileList(window.currentFolder);
|
||||||
|
|
||||||
const fileListArea = document.getElementById('fileListContainer');
|
const fileListArea = document.getElementById('fileListContainer');
|
||||||
const uploadArea = document.getElementById('uploadDropArea');
|
const uploadArea = document.getElementById('uploadDropArea');
|
||||||
if (fileListArea && uploadArea) {
|
if (fileListArea && uploadArea) {
|
||||||
@@ -35,7 +125,6 @@ export function initializeApp() {
|
|||||||
fileListArea.addEventListener('drop', e => {
|
fileListArea.addEventListener('drop', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fileListArea.classList.remove('drop-hover');
|
fileListArea.classList.remove('drop-hover');
|
||||||
// re-dispatch the same drop into the real upload card
|
|
||||||
uploadArea.dispatchEvent(new DragEvent('drop', {
|
uploadArea.dispatchEvent(new DragEvent('drop', {
|
||||||
dataTransfer: e.dataTransfer,
|
dataTransfer: e.dataTransfer,
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
@@ -50,8 +139,12 @@ export function initializeApp() {
|
|||||||
initFileActions();
|
initFileActions();
|
||||||
initUpload();
|
initUpload();
|
||||||
loadFolderTree();
|
loadFolderTree();
|
||||||
|
// Only run trash/restore for admins
|
||||||
|
const isAdmin =
|
||||||
|
localStorage.getItem('isAdmin') === '1' || localStorage.getItem('isAdmin') === 'true';
|
||||||
|
if (isAdmin) {
|
||||||
setupTrashRestoreDelete();
|
setupTrashRestoreDelete();
|
||||||
loadAdminConfigFunc();
|
}
|
||||||
|
|
||||||
const helpBtn = document.getElementById("folderHelpBtn");
|
const helpBtn = document.getElementById("folderHelpBtn");
|
||||||
const helpTooltip = document.getElementById("folderHelpTooltip");
|
const helpTooltip = document.getElementById("folderHelpTooltip");
|
||||||
@@ -63,27 +156,36 @@ export function initializeApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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', { method: 'GET' })
|
return _nativeFetch('/api/auth/token.php', { method: 'GET', credentials: 'include' })
|
||||||
.then(res => {
|
.then(async res => {
|
||||||
if (!res.ok) throw new Error(`Token fetch failed with status ${res.status}`);
|
// header-based rotation
|
||||||
return res.json();
|
const hdr = res.headers.get('X-CSRF-Token');
|
||||||
})
|
if (hdr) setCsrfToken(hdr);
|
||||||
.then(({ csrf_token, share_url }) => {
|
|
||||||
window.csrfToken = csrf_token;
|
|
||||||
|
|
||||||
// update CSRF meta
|
// body (if provided)
|
||||||
let meta = document.querySelector('meta[name="csrf-token"]') ||
|
let body = {};
|
||||||
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'csrf-token' });
|
try { body = await res.json(); } catch { /* token endpoint may return empty */ }
|
||||||
meta.content = csrf_token;
|
|
||||||
|
|
||||||
// force share_url to match wherever we're browsing
|
const token = body.csrf_token || getCsrfToken();
|
||||||
|
setCsrfToken(token);
|
||||||
|
|
||||||
|
// share-url meta should reflect the actual origin
|
||||||
const actualShare = window.location.origin;
|
const actualShare = window.location.origin;
|
||||||
let shareMeta = document.querySelector('meta[name="share-url"]') ||
|
let shareMeta = document.querySelector('meta[name="share-url"]');
|
||||||
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'share-url' });
|
if (!shareMeta) {
|
||||||
|
shareMeta = document.createElement('meta');
|
||||||
|
shareMeta.name = 'share-url';
|
||||||
|
document.head.appendChild(shareMeta);
|
||||||
|
}
|
||||||
shareMeta.content = actualShare;
|
shareMeta.content = actualShare;
|
||||||
|
|
||||||
return { csrf_token, share_url: actualShare };
|
return { csrf_token: token, share_url: actualShare };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,16 +197,15 @@ if (params.get('logout') === '1') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function triggerLogout() {
|
export function triggerLogout() {
|
||||||
fetch("/api/auth/logout.php", {
|
_nativeFetch("/api/auth/logout.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "X-CSRF-Token": window.csrfToken }
|
headers: { "X-CSRF-Token": getCsrfToken() }
|
||||||
})
|
})
|
||||||
.then(() => window.location.reload(true))
|
.then(() => window.location.reload(true))
|
||||||
.catch(() => { });
|
.catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Expose functions for inline handlers.
|
// Expose functions for inline handlers.
|
||||||
window.sendRequest = sendRequest;
|
window.sendRequest = sendRequest;
|
||||||
window.toggleVisibility = toggleVisibility;
|
window.toggleVisibility = toggleVisibility;
|
||||||
@@ -119,20 +220,21 @@ window.openDownloadModal = openDownloadModal;
|
|||||||
window.currentFolder = "root";
|
window.currentFolder = "root";
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Load admin config early
|
||||||
|
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(() => {
|
// 1) Get/refresh CSRF first
|
||||||
// Once CSRF token is loaded, initialize authentication.
|
loadCsrfToken()
|
||||||
|
.then(() => {
|
||||||
|
// 2) Auth boot
|
||||||
initAuth();
|
initAuth();
|
||||||
|
|
||||||
// Continue with initializations that rely on a valid CSRF token:
|
// 3) If authenticated, start app
|
||||||
checkAuthentication().then(authenticated => {
|
checkAuthentication().then(authenticated => {
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
const overlay = document.getElementById('loadingOverlay');
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
@@ -141,26 +243,14 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Other DOM initialization that can happen after CSRF is ready.
|
|
||||||
const newPasswordInput = document.getElementById("newPassword");
|
|
||||||
if (newPasswordInput) {
|
|
||||||
newPasswordInput.addEventListener("input", function () {
|
|
||||||
console.log("newPassword input event:", this.value);
|
|
||||||
});
|
|
||||||
} 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);
|
||||||
@@ -168,36 +258,22 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
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(
|
darkModeToggle.setAttribute("aria-label", dark ? t("light_mode") : t("dark_mode"));
|
||||||
"aria-label",
|
darkModeToggle.setAttribute("title", dark ? t("switch_to_light_mode") : t("switch_to_dark_mode"));
|
||||||
dark ? t("light_mode") : t("dark_mode")
|
|
||||||
);
|
|
||||||
darkModeToggle.setAttribute(
|
|
||||||
"title",
|
|
||||||
dark
|
|
||||||
? t("switch_to_light_mode")
|
|
||||||
: t("switch_to_dark_mode")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateIcon();
|
updateIcon();
|
||||||
|
|
||||||
// 4) Click handler: always override and store preference
|
|
||||||
darkModeToggle.addEventListener("click", () => {
|
darkModeToggle.addEventListener("click", () => {
|
||||||
const nowDark = document.body.classList.toggle("dark-mode");
|
const nowDark = document.body.classList.toggle("dark-mode");
|
||||||
localStorage.setItem("darkMode", nowDark ? "true" : "false");
|
localStorage.setItem("darkMode", nowDark ? "true" : "false");
|
||||||
updateIcon();
|
updateIcon();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5) OS‐level change: only if no stored pref at load
|
|
||||||
if (!hasStored && window.matchMedia) {
|
if (!hasStored && window.matchMedia) {
|
||||||
window
|
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => {
|
||||||
.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();
|
||||||
});
|
});
|
||||||
@@ -210,14 +286,14 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
showToast(message);
|
showToast(message);
|
||||||
sessionStorage.removeItem("welcomeMessage");
|
sessionStorage.removeItem("welcomeMessage");
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
})
|
||||||
|
.catch(error => {
|
||||||
console.error("Initialization halted due to CSRF token load failure.", error);
|
console.error("Initialization halted due to CSRF token load failure.", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Auto-scroll During Drag ---
|
// --- Auto-scroll During Drag ---
|
||||||
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling
|
const SCROLL_THRESHOLD = 50;
|
||||||
const SCROLL_SPEED = 20; // pixels to scroll per event
|
const SCROLL_SPEED = 20;
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ 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();
|
||||||
@@ -27,42 +29,46 @@ if (!$enableWebDAV) {
|
|||||||
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'] ?? '';
|
||||||
|
if ($user === '') {
|
||||||
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
|
header('WWW-Authenticate: Basic realm="FileRise"');
|
||||||
|
echo 'Authentication required.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$perms = is_callable('loadUserPermissions') ? (loadUserPermissions($user) ?: []) : [];
|
||||||
$isAdmin = (\AuthModel::getUserRole($user) === '1');
|
$isAdmin = (\AuthModel::getUserRole($user) === '1');
|
||||||
$folderOnly = (bool)\AuthModel::loadFolderPermission($user);
|
|
||||||
|
|
||||||
if ($isAdmin || !$folderOnly) {
|
// set for metadata attribution in WebDAV writes
|
||||||
// Admins (or users without folder-only restriction) see the full /uploads
|
CurrentUser::set($user);
|
||||||
|
|
||||||
|
// ─── 5) Mount the real uploads root; ACL filters everything at node level ───
|
||||||
$rootPath = rtrim(UPLOAD_DIR, '/\\');
|
$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 ────────────────────────────────────────────────────
|
|
||||||
$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();
|
||||||
@@ -53,6 +53,8 @@ class AdminController
|
|||||||
public function getConfig(): void
|
public function getConfig(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Load raw config (no disclosure yet)
|
||||||
$config = AdminModel::getConfig();
|
$config = AdminModel::getConfig();
|
||||||
if (isset($config['error'])) {
|
if (isset($config['error'])) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
@@ -60,22 +62,42 @@ class AdminController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a safe subset for the front-end
|
// Minimal, safe subset for all callers (unauth users and regular users)
|
||||||
$safe = [
|
$public = [
|
||||||
'header_title' => $config['header_title'],
|
'header_title' => $config['header_title'] ?? 'FileRise',
|
||||||
'loginOptions' => $config['loginOptions'],
|
'loginOptions' => [
|
||||||
'globalOtpauthUrl' => $config['globalOtpauthUrl'],
|
// expose only what the login page / header needs
|
||||||
'enableWebDAV' => $config['enableWebDAV'],
|
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
|
||||||
'sharedMaxUploadSize' => $config['sharedMaxUploadSize'],
|
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
|
||||||
|
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
|
||||||
|
],
|
||||||
|
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
|
||||||
|
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
|
||||||
|
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
|
||||||
|
|
||||||
'oidc' => [
|
'oidc' => [
|
||||||
'providerUrl' => $config['oidc']['providerUrl'],
|
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||||
'redirectUri' => $config['oidc']['redirectUri'],
|
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||||
// clientSecret and clientId never exposed here
|
// never expose clientId / clientSecret
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
echo json_encode($safe);
|
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
|
||||||
exit;
|
|
||||||
|
if ($isAdmin) {
|
||||||
|
// Add admin-only fields (used by Admin Panel UI)
|
||||||
|
$adminExtra = [
|
||||||
|
'loginOptions' => array_merge($public['loginOptions'], [
|
||||||
|
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
|
||||||
|
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
echo json_encode(array_merge($public, $adminExtra));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-admins / unauthenticated: only the public subset
|
||||||
|
echo json_encode($public);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,7 +172,7 @@ class AdminController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||||
$receivedToken = trim($headersArr['x-csrf-token'] ?? '');
|
$receivedToken = trim($headersArr['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
|
||||||
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.']);
|
||||||
@@ -167,23 +189,45 @@ class AdminController
|
|||||||
|
|
||||||
// —– load existing on-disk config —–
|
// —– load existing on-disk config —–
|
||||||
$existing = AdminModel::getConfig();
|
$existing = AdminModel::getConfig();
|
||||||
|
if (isset($existing['error'])) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => $existing['error']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// —– start merge with existing as base —–
|
// —– start merge with existing as base —–
|
||||||
$merged = $existing;
|
// Ensure minimal structure if the file was partially missing.
|
||||||
|
$merged = $existing + [
|
||||||
|
'header_title' => '',
|
||||||
|
'loginOptions' => [
|
||||||
|
'disableFormLogin' => false,
|
||||||
|
'disableBasicAuth' => false,
|
||||||
|
'disableOIDCLogin' => true,
|
||||||
|
'authBypass' => false,
|
||||||
|
'authHeaderName' => 'X-Remote-User'
|
||||||
|
],
|
||||||
|
'globalOtpauthUrl' => '',
|
||||||
|
'enableWebDAV' => false,
|
||||||
|
'sharedMaxUploadSize' => 0,
|
||||||
|
'oidc' => [
|
||||||
|
'providerUrl' => '',
|
||||||
|
'clientId' => '',
|
||||||
|
'clientSecret'=> '',
|
||||||
|
'redirectUri' => ''
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
// header_title
|
// header_title (cap length and strip control chars)
|
||||||
if (array_key_exists('header_title', $data)) {
|
if (array_key_exists('header_title', $data)) {
|
||||||
$merged['header_title'] = trim($data['header_title']);
|
$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
|
// loginOptions: inherit existing then override if provided
|
||||||
$merged['loginOptions'] = $existing['loginOptions'] ?? [
|
|
||||||
'disableFormLogin' => false,
|
|
||||||
'disableBasicAuth' => false,
|
|
||||||
'disableOIDCLogin'=> false,
|
|
||||||
'authBypass' => false,
|
|
||||||
'authHeaderName' => 'X-Remote-User'
|
|
||||||
];
|
|
||||||
foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) {
|
foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) {
|
||||||
if (isset($data['loginOptions'][$flag])) {
|
if (isset($data['loginOptions'][$flag])) {
|
||||||
$merged['loginOptions'][$flag] = filter_var(
|
$merged['loginOptions'][$flag] = filter_var(
|
||||||
@@ -193,15 +237,20 @@ class AdminController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isset($data['loginOptions']['authHeaderName'])) {
|
if (isset($data['loginOptions']['authHeaderName'])) {
|
||||||
$hdr = trim($data['loginOptions']['authHeaderName']);
|
$hdr = trim((string)$data['loginOptions']['authHeaderName']);
|
||||||
if ($hdr !== '') {
|
// very restrictive header-name pattern: letters, numbers, dashes
|
||||||
|
if ($hdr !== '' && preg_match('/^[A-Za-z0-9\-]+$/', $hdr)) {
|
||||||
$merged['loginOptions']['authHeaderName'] = $hdr;
|
$merged['loginOptions']['authHeaderName'] = $hdr;
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Invalid authHeaderName.']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// globalOtpauthUrl
|
// globalOtpauthUrl
|
||||||
if (array_key_exists('globalOtpauthUrl', $data)) {
|
if (array_key_exists('globalOtpauthUrl', $data)) {
|
||||||
$merged['globalOtpauthUrl'] = trim($data['globalOtpauthUrl']);
|
$merged['globalOtpauthUrl'] = trim((string)$data['globalOtpauthUrl']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// enableWebDAV
|
// enableWebDAV
|
||||||
@@ -212,18 +261,25 @@ class AdminController
|
|||||||
// sharedMaxUploadSize
|
// sharedMaxUploadSize
|
||||||
if (array_key_exists('sharedMaxUploadSize', $data)) {
|
if (array_key_exists('sharedMaxUploadSize', $data)) {
|
||||||
$sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
|
$sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
|
||||||
if ($sms !== false) {
|
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;
|
$merged['sharedMaxUploadSize'] = $sms;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// oidc: only overwrite non-empty inputs
|
// oidc: only overwrite non-empty inputs; validate when enabling OIDC
|
||||||
$merged['oidc'] = $existing['oidc'] ?? [
|
|
||||||
'providerUrl'=>'','clientId'=>'','clientSecret'=>'','redirectUri'=>''
|
|
||||||
];
|
|
||||||
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) {
|
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) {
|
||||||
if (!empty($data['oidc'][$f])) {
|
if (!empty($data['oidc'][$f])) {
|
||||||
$val = trim($data['oidc'][$f]);
|
$val = trim((string)$data['oidc'][$f]);
|
||||||
if ($f === 'providerUrl' || $f === 'redirectUri') {
|
if ($f === 'providerUrl' || $f === 'redirectUri') {
|
||||||
$val = filter_var($val, FILTER_SANITIZE_URL);
|
$val = filter_var($val, FILTER_SANITIZE_URL);
|
||||||
}
|
}
|
||||||
@@ -231,6 +287,36 @@ class AdminController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 —–
|
// —– persist merged config —–
|
||||||
$result = AdminModel::updateConfig($merged);
|
$result = AdminModel::updateConfig($merged);
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
@@ -239,4 +325,20 @@ class AdminController
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
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 {
|
||||||
@@ -73,68 +74,79 @@ 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
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,24 +188,21 @@ 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,127 @@
|
|||||||
<?php
|
<?php
|
||||||
// UserController.php located in src/controllers/
|
// src/controllers/UserController.php
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserController
|
||||||
|
* - Hardened CSRF/auth checks (works even when getallheaders() is unavailable)
|
||||||
|
* - Consistent method checks without breaking existing clients (accepts POST as fallback for some endpoints)
|
||||||
|
* - Stricter validation & safer defaults
|
||||||
|
* - Fixed TOTP setup bug for pending-login users
|
||||||
|
* - Standardized calls to UserModel (proper case)
|
||||||
|
*/
|
||||||
class UserController
|
class UserController
|
||||||
{
|
{
|
||||||
|
/* ---------- Small internal helpers to reduce repetition ---------- */
|
||||||
|
|
||||||
|
/** Get headers in lowercase, robust across SAPIs. */
|
||||||
|
private static function headersLower(): array
|
||||||
|
{
|
||||||
|
$headers = function_exists('getallheaders') ? getallheaders() : [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($headers as $k => $v) {
|
||||||
|
$out[strtolower($k)] = $v;
|
||||||
|
}
|
||||||
|
// Fallbacks from $_SERVER if needed
|
||||||
|
foreach ($_SERVER as $k => $v) {
|
||||||
|
if (strpos($k, 'HTTP_') === 0) {
|
||||||
|
$h = strtolower(str_replace('_', '-', substr($k, 5)));
|
||||||
|
if (!isset($out[$h])) $out[$h] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enforce allowed HTTP method(s); default to 405 if not allowed. */
|
||||||
|
private static function requireMethod(array $allowed): void
|
||||||
|
{
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
if (!in_array($method, $allowed, true)) {
|
||||||
|
http_response_code(405);
|
||||||
|
header('Allow: ' . implode(', ', $allowed));
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enforce authentication (401). */
|
||||||
|
private static function requireAuth(): void
|
||||||
|
{
|
||||||
|
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enforce admin (401). */
|
||||||
|
private static function requireAdmin(): void
|
||||||
|
{
|
||||||
|
self::requireAuth();
|
||||||
|
|
||||||
|
// Prefer the session flag
|
||||||
|
$isAdmin = (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true);
|
||||||
|
|
||||||
|
// Fallback: check the user’s role in storage (e.g., users.txt/DB)
|
||||||
|
if (!$isAdmin) {
|
||||||
|
$u = $_SESSION['username'] ?? '';
|
||||||
|
if ($u) {
|
||||||
|
try {
|
||||||
|
// UserModel::getUserRole($u) should return '1' for admins
|
||||||
|
$isAdmin = (UserModel::getUserRole($u) === '1');
|
||||||
|
if ($isAdmin) {
|
||||||
|
// Normalize session so downstream ACL checks see admin
|
||||||
|
$_SESSION['isAdmin'] = true;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore and continue to deny
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'Admin privileges required.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
|
||||||
|
private static function requireCsrf(): void
|
||||||
|
{
|
||||||
|
$h = self::headersLower();
|
||||||
|
$token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
|
||||||
|
if (empty($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read JSON body (empty array if not valid). */
|
||||||
|
private static function readJson(): array
|
||||||
|
{
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: set JSON content type + no-store. */
|
||||||
|
private static function jsonHeaders(): void
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- End helpers -------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
* path="/api/getUsers.php",
|
* path="/api/getUsers.php",
|
||||||
@@ -31,24 +147,15 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function getUsers()
|
public function getUsers()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
self::jsonHeaders();
|
||||||
|
self::requireAdmin();
|
||||||
// Check authentication and admin privileges.
|
|
||||||
if (
|
|
||||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
|
||||||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
|
||||||
) {
|
|
||||||
http_response_code(401);
|
|
||||||
echo json_encode(["error" => "Unauthorized"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve users using the model
|
// Retrieve users using the model
|
||||||
$users = userModel::getAllUsers();
|
$users = UserModel::getAllUsers();
|
||||||
echo json_encode($users);
|
echo json_encode($users);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,34 +191,33 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function addUser()
|
public function addUser()
|
||||||
{
|
{
|
||||||
// 1) Ensure JSON output and session
|
self::jsonHeaders();
|
||||||
header('Content-Type: application/json');
|
self::requireMethod(['POST']);
|
||||||
|
|
||||||
// 1a) Initialize CSRF token if missing
|
// Initialize CSRF token if missing (useful for initial page load)
|
||||||
if (empty($_SESSION['csrf_token'])) {
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Determine setup mode (first-ever admin creation)
|
// Setup mode detection (first-run bootstrap)
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
|
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
|
||||||
$setupMode = false;
|
$setupMode = false;
|
||||||
if (
|
if (
|
||||||
$isSetup && (!file_exists($usersFile)
|
$isSetup && (!file_exists($usersFile)
|
||||||
|| filesize($usersFile) === 0
|
|| filesize($usersFile) === 0
|
||||||
|| trim(file_get_contents($usersFile)) === ''
|
|| trim(@file_get_contents($usersFile)) === ''
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
$setupMode = true;
|
$setupMode = true;
|
||||||
} else {
|
} else {
|
||||||
// 3) In non-setup, enforce CSRF + auth checks
|
// Not setup: enforce CSRF + admin auth
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
$h = self::headersLower();
|
||||||
$receivedToken = trim($headersArr['x-csrf-token'] ?? '');
|
$receivedToken = trim($h['x-csrf-token'] ?? '');
|
||||||
|
|
||||||
// 3a) Soft-fail CSRF: on mismatch, regenerate and return new token
|
// Soft-fail CSRF: on mismatch, regenerate and return new token (preserve your current UX)
|
||||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
|
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
|
||||||
@@ -122,31 +228,15 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3b) Must be logged in as admin
|
self::requireAdmin();
|
||||||
if (
|
|
||||||
empty($_SESSION['authenticated'])
|
|
||||||
|| $_SESSION['authenticated'] !== true
|
|
||||||
|| empty($_SESSION['isAdmin'])
|
|
||||||
|| $_SESSION['isAdmin'] !== true
|
|
||||||
) {
|
|
||||||
echo json_encode(["error" => "Unauthorized"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Parse input
|
$data = self::readJson();
|
||||||
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
||||||
$newUsername = trim($data['username'] ?? '');
|
$newUsername = trim($data['username'] ?? '');
|
||||||
$newPassword = trim($data['password'] ?? '');
|
$newPassword = trim($data['password'] ?? '');
|
||||||
|
|
||||||
// 5) Determine admin flag
|
$isAdmin = $setupMode ? '1' : (!empty($data['isAdmin']) ? '1' : '0');
|
||||||
if ($setupMode) {
|
|
||||||
$isAdmin = '1';
|
|
||||||
} else {
|
|
||||||
$isAdmin = !empty($data['isAdmin']) ? '1' : '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Validate fields
|
|
||||||
if ($newUsername === '' || $newPassword === '') {
|
if ($newUsername === '' || $newPassword === '') {
|
||||||
echo json_encode(["error" => "Username and password required"]);
|
echo json_encode(["error" => "Username and password required"]);
|
||||||
exit;
|
exit;
|
||||||
@@ -157,11 +247,13 @@ class UserController
|
|||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
// Keep password rules lenient to avoid breaking existing flows; enforce at least 6 chars
|
||||||
|
if (strlen($newPassword) < 6) {
|
||||||
|
echo json_encode(["error" => "Password must be at least 6 characters."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// 7) Delegate to model
|
$result = UserModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
|
||||||
$result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
|
|
||||||
|
|
||||||
// 8) Return model result
|
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -201,54 +293,33 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function removeUser()
|
public function removeUser()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
self::jsonHeaders();
|
||||||
|
// Accept DELETE or POST for broader compatibility
|
||||||
|
self::requireMethod(['DELETE', 'POST']);
|
||||||
|
self::requireAdmin();
|
||||||
|
self::requireCsrf();
|
||||||
|
|
||||||
// CSRF token check.
|
$data = self::readJson();
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
$usernameToRemove = trim($data['username'] ?? '');
|
||||||
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
||||||
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authentication and admin check.
|
if ($usernameToRemove === '') {
|
||||||
if (
|
|
||||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
|
||||||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
|
||||||
) {
|
|
||||||
http_response_code(401);
|
|
||||||
echo json_encode(["error" => "Unauthorized"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve JSON data.
|
|
||||||
$data = json_decode(file_get_contents("php://input"), true);
|
|
||||||
$usernameToRemove = trim($data["username"] ?? "");
|
|
||||||
|
|
||||||
if (!$usernameToRemove) {
|
|
||||||
echo json_encode(["error" => "Username is required"]);
|
echo json_encode(["error" => "Username is required"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the username format.
|
|
||||||
if (!preg_match(REGEX_USER, $usernameToRemove)) {
|
if (!preg_match(REGEX_USER, $usernameToRemove)) {
|
||||||
echo json_encode(["error" => "Invalid username format"]);
|
echo json_encode(["error" => "Invalid username format"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
if (!empty($_SESSION['username']) && $_SESSION['username'] === $usernameToRemove) {
|
||||||
// Prevent removal of the currently logged-in user.
|
|
||||||
if (isset($_SESSION['username']) && $_SESSION['username'] === $usernameToRemove) {
|
|
||||||
echo json_encode(["error" => "Cannot remove yourself"]);
|
echo json_encode(["error" => "Cannot remove yourself"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate the removal logic to the model.
|
$result = UserModel::removeUser($usernameToRemove);
|
||||||
$result = userModel::removeUser($usernameToRemove);
|
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -269,21 +340,14 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function getUserPermissions()
|
public function getUserPermissions()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
self::jsonHeaders();
|
||||||
|
self::requireAuth();
|
||||||
|
|
||||||
// Check if the user is authenticated.
|
$permissions = UserModel::getUserPermissions();
|
||||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
|
||||||
http_response_code(401);
|
|
||||||
echo json_encode(["error" => "Unauthorized"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegate to the model.
|
|
||||||
$permissions = userModel::getUserPermissions();
|
|
||||||
echo json_encode($permissions);
|
echo json_encode($permissions);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,42 +395,24 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function updateUserPermissions()
|
public function updateUserPermissions()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
self::jsonHeaders();
|
||||||
|
// Accept PUT or POST for compatibility with clients that can't send PUT
|
||||||
|
self::requireMethod(['PUT', 'POST']);
|
||||||
|
self::requireAdmin();
|
||||||
|
self::requireCsrf();
|
||||||
|
|
||||||
// Only admins can update permissions.
|
$input = self::readJson();
|
||||||
if (
|
|
||||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
|
||||||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
|
||||||
) {
|
|
||||||
http_response_code(401);
|
|
||||||
echo json_encode(["error" => "Unauthorized"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify CSRF token from headers.
|
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
||||||
$csrfToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
||||||
if (!isset($_SESSION['csrf_token']) || $csrfToken !== $_SESSION['csrf_token']) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get POST input.
|
|
||||||
$input = json_decode(file_get_contents("php://input"), true);
|
|
||||||
if (!isset($input['permissions']) || !is_array($input['permissions'])) {
|
if (!isset($input['permissions']) || !is_array($input['permissions'])) {
|
||||||
echo json_encode(["error" => "Invalid input"]);
|
echo json_encode(["error" => "Invalid input"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$permissions = $input['permissions'];
|
$permissions = $input['permissions'];
|
||||||
|
|
||||||
// Delegate to the model.
|
$result = UserModel::updateUserPermissions($permissions);
|
||||||
$result = userModel::updateUserPermissions($permissions);
|
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -406,41 +452,25 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function changePassword()
|
public function changePassword()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
self::jsonHeaders();
|
||||||
|
self::requireMethod(['POST']);
|
||||||
// Ensure user is authenticated.
|
self::requireAuth();
|
||||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
self::requireCsrf();
|
||||||
http_response_code(401);
|
|
||||||
echo json_encode(["error" => "Unauthorized"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
if (!$username) {
|
if ($username === '') {
|
||||||
echo json_encode(["error" => "No username in session"]);
|
echo json_encode(["error" => "No username in session"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF token check.
|
$data = self::readJson();
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
||||||
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
||||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get POST data.
|
|
||||||
$data = json_decode(file_get_contents("php://input"), true);
|
|
||||||
$oldPassword = trim($data["oldPassword"] ?? "");
|
$oldPassword = trim($data["oldPassword"] ?? "");
|
||||||
$newPassword = trim($data["newPassword"] ?? "");
|
$newPassword = trim($data["newPassword"] ?? "");
|
||||||
$confirmPassword = trim($data["confirmPassword"] ?? "");
|
$confirmPassword = trim($data["confirmPassword"] ?? "");
|
||||||
|
|
||||||
// Validate input.
|
if ($oldPassword === '' || $newPassword === '' || $confirmPassword === '') {
|
||||||
if (!$oldPassword || !$newPassword || !$confirmPassword) {
|
|
||||||
echo json_encode(["error" => "All fields are required."]);
|
echo json_encode(["error" => "All fields are required."]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -448,10 +478,14 @@ class UserController
|
|||||||
echo json_encode(["error" => "New passwords do not match."]);
|
echo json_encode(["error" => "New passwords do not match."]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
if (strlen($newPassword) < 6) {
|
||||||
|
echo json_encode(["error" => "Password must be at least 6 characters."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Delegate password change logic to the model.
|
$result = UserModel::changePassword($username, $oldPassword, $newPassword);
|
||||||
$result = userModel::changePassword($username, $oldPassword, $newPassword);
|
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -489,29 +523,15 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function updateUserPanel()
|
public function updateUserPanel()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
self::jsonHeaders();
|
||||||
|
// Accept PUT or POST for compatibility
|
||||||
|
self::requireMethod(['PUT', 'POST']);
|
||||||
|
self::requireAuth();
|
||||||
|
self::requireCsrf();
|
||||||
|
|
||||||
// Check if the user is authenticated.
|
$data = self::readJson();
|
||||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(["error" => "Unauthorized"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the CSRF token.
|
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
||||||
$csrfToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
||||||
if (!isset($_SESSION['csrf_token']) || $csrfToken !== $_SESSION['csrf_token']) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the POST input.
|
|
||||||
$data = json_decode(file_get_contents("php://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"]);
|
||||||
@@ -519,18 +539,16 @@ class UserController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
if (!$username) {
|
if ($username === '') {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(["error" => "No username in session"]);
|
echo json_encode(["error" => "No username in session"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract totp_enabled, converting it to boolean.
|
|
||||||
$totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false;
|
$totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false;
|
||||||
|
$result = UserModel::updateUserPanel($username, $totp_enabled);
|
||||||
// Delegate to the model.
|
|
||||||
$result = userModel::updateUserPanel($username, $totp_enabled);
|
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -558,43 +576,29 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function disableTOTP()
|
public function disableTOTP()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
self::jsonHeaders();
|
||||||
|
// Accept PUT or POST
|
||||||
// Authentication check.
|
self::requireMethod(['PUT', 'POST']);
|
||||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
self::requireAuth();
|
||||||
http_response_code(403);
|
self::requireCsrf();
|
||||||
echo json_encode(["error" => "Not authenticated"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
if (empty($username)) {
|
if ($username === '') {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(["error" => "Username not found in session"]);
|
echo json_encode(["error" => "Username not found in session"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF token check.
|
$result = UserModel::disableTOTPSecret($username);
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
||||||
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
||||||
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegate the TOTP disabling logic to the model.
|
|
||||||
$result = userModel::disableTOTPSecret($username);
|
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
|
echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
|
||||||
} else {
|
} else {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(["error" => "Failed to disable TOTP."]);
|
echo json_encode(["error" => "Failed to disable TOTP."]);
|
||||||
}
|
}
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -636,61 +640,45 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function recoverTOTP()
|
public function recoverTOTP()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
self::jsonHeaders();
|
||||||
|
self::requireMethod(['POST']);
|
||||||
|
self::requireCsrf();
|
||||||
|
|
||||||
// 1) Only allow POST.
|
$userId = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? null);
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
http_response_code(405);
|
|
||||||
exit(json_encode(['status' => 'error', 'message' => 'Method not allowed']));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) CSRF check.
|
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
||||||
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
||||||
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
|
||||||
http_response_code(403);
|
|
||||||
exit(json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Identify the user.
|
|
||||||
$userId = $_SESSION['username'] ?? $_SESSION['pending_login_user'] ?? null;
|
|
||||||
if (!$userId) {
|
if (!$userId) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
exit(json_encode(['status' => 'error', 'message' => 'Unauthorized']));
|
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Validate userId format.
|
|
||||||
if (!preg_match(REGEX_USER, $userId)) {
|
if (!preg_match(REGEX_USER, $userId)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
exit(json_encode(['status' => 'error', 'message' => 'Invalid user identifier']));
|
echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Get the recovery code from input.
|
$inputData = self::readJson();
|
||||||
$inputData = json_decode(file_get_contents("php://input"), true);
|
|
||||||
$recoveryCode = $inputData['recovery_code'] ?? '';
|
$recoveryCode = $inputData['recovery_code'] ?? '';
|
||||||
|
|
||||||
// 6) Delegate to the model.
|
$result = UserModel::recoverTOTP($userId, $recoveryCode);
|
||||||
$result = userModel::recoverTOTP($userId, $recoveryCode);
|
|
||||||
|
|
||||||
if ($result['status'] === 'ok') {
|
if (($result['status'] ?? '') === 'ok') {
|
||||||
// 7) Finalize login.
|
// Finalize login
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$_SESSION['authenticated'] = true;
|
$_SESSION['authenticated'] = true;
|
||||||
$_SESSION['username'] = $userId;
|
$_SESSION['username'] = $userId;
|
||||||
unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']);
|
unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']);
|
||||||
echo json_encode(['status' => 'ok']);
|
echo json_encode(['status' => 'ok']);
|
||||||
} else {
|
} else {
|
||||||
// Set appropriate HTTP code for errors.
|
if (($result['message'] ?? '') === 'Too many attempts. Try again later.') {
|
||||||
if ($result['message'] === 'Too many attempts. Try again later.') {
|
|
||||||
http_response_code(429);
|
http_response_code(429);
|
||||||
} else {
|
} else {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
}
|
}
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
}
|
}
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -722,49 +710,33 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function saveTOTPRecoveryCode()
|
public function saveTOTPRecoveryCode()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
self::jsonHeaders();
|
||||||
|
self::requireMethod(['POST']);
|
||||||
|
self::requireCsrf();
|
||||||
|
|
||||||
// 1) Only allow POST requests.
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
http_response_code(405);
|
|
||||||
error_log("totp_saveCode: invalid method {$_SERVER['REQUEST_METHOD']}");
|
|
||||||
exit(json_encode(['status' => 'error', 'message' => 'Method not allowed']));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) CSRF token check.
|
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
||||||
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
||||||
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
|
||||||
http_response_code(403);
|
|
||||||
exit(json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Ensure the user is authenticated.
|
|
||||||
if (empty($_SESSION['username'])) {
|
if (empty($_SESSION['username'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
error_log("totp_saveCode: unauthorized attempt from IP {$_SERVER['REMOTE_ADDR']}");
|
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
|
||||||
exit(json_encode(['status' => 'error', 'message' => 'Unauthorized']));
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Validate the username format.
|
|
||||||
$userId = $_SESSION['username'];
|
$userId = $_SESSION['username'];
|
||||||
if (!preg_match(REGEX_USER, $userId)) {
|
if (!preg_match(REGEX_USER, $userId)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
error_log("totp_saveCode: invalid username format: {$userId}");
|
echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
|
||||||
exit(json_encode(['status' => 'error', 'message' => 'Invalid user identifier']));
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Delegate to the model.
|
$result = UserModel::saveTOTPRecoveryCode($userId);
|
||||||
$result = userModel::saveTOTPRecoveryCode($userId);
|
if (($result['status'] ?? '') === 'ok') {
|
||||||
if ($result['status'] === 'ok') {
|
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
} else {
|
} else {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
}
|
}
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -791,43 +763,40 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function setupTOTP()
|
public function setupTOTP()
|
||||||
{
|
{
|
||||||
// Allow access if the user is authenticated or pending TOTP.
|
// Allow access if authenticated OR pending TOTP
|
||||||
if (!( (isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']) )) {
|
if (!( (isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']) )) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
exit(json_encode(["error" => "Not authorized to access TOTP setup"]));
|
header('Content-Type: application/json');
|
||||||
}
|
echo json_encode(["error" => "Not authorized to access TOTP setup"]);
|
||||||
|
|
||||||
// Verify CSRF token from headers.
|
|
||||||
$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']) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$username = $_SESSION['username'] ?? '';
|
self::requireCsrf();
|
||||||
if (!$username) {
|
|
||||||
|
// Fix: if username not present (pending flow), fall back to pending_login_user
|
||||||
|
$username = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? '');
|
||||||
|
if ($username === '') {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'Username not available for TOTP setup']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set header for PNG output.
|
|
||||||
header("Content-Type: image/png");
|
header("Content-Type: image/png");
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
// Delegate the TOTP setup work to the model.
|
$result = UserModel::setupTOTP($username);
|
||||||
$result = userModel::setupTOTP($username);
|
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
echo json_encode(["error" => $result['error']]);
|
echo json_encode(["error" => $result['error']]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output the QR code image.
|
|
||||||
echo $result['imageData'];
|
echo $result['imageData'];
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -866,11 +835,11 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function verifyTOTP()
|
public function verifyTOTP()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
// Rate-limit
|
// Rate-limit
|
||||||
if (!isset($_SESSION['totp_failures'])) {
|
if (!isset($_SESSION['totp_failures'])) {
|
||||||
@@ -890,16 +859,10 @@ class UserController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CSRF check
|
// CSRF check
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
self::requireCsrf();
|
||||||
$csrfHeader = $headersArr['x-csrf-token'] ?? '';
|
|
||||||
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse & validate input
|
// Parse & validate input
|
||||||
$inputData = json_decode(file_get_contents("php://input"), true);
|
$inputData = self::readJson();
|
||||||
$code = trim($inputData['totp_code'] ?? '');
|
$code = trim($inputData['totp_code'] ?? '');
|
||||||
if (!preg_match('/^\d{6}$/', $code)) {
|
if (!preg_match('/^\d{6}$/', $code)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
@@ -916,7 +879,7 @@ class UserController
|
|||||||
\RobThree\Auth\Algorithm::Sha1
|
\RobThree\Auth\Algorithm::Sha1
|
||||||
);
|
);
|
||||||
|
|
||||||
// === Pending-login flow (we just came from auth and need to finish login) ===
|
// Pending-login flow
|
||||||
if (isset($_SESSION['pending_login_user'])) {
|
if (isset($_SESSION['pending_login_user'])) {
|
||||||
$username = $_SESSION['pending_login_user'];
|
$username = $_SESSION['pending_login_user'];
|
||||||
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
|
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
|
||||||
@@ -939,13 +902,14 @@ class UserController
|
|||||||
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
||||||
$all = json_decode($dec, true) ?: [];
|
$all = json_decode($dec, true) ?: [];
|
||||||
}
|
}
|
||||||
|
$perms = loadUserPermissions($username);
|
||||||
$all[$token] = [
|
$all[$token] = [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
'expiry' => $expiry,
|
'expiry' => $expiry,
|
||||||
'isAdmin' => ((int)userModel::getUserRole($username) === 1),
|
'isAdmin' => ((int)UserModel::getUserRole($username) === 1),
|
||||||
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
|
'folderOnly' => $perms['folderOnly'] ?? false,
|
||||||
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
|
'readOnly' => $perms['readOnly'] ?? false,
|
||||||
'disableUpload' => loadUserPermissions($username)['disableUpload'] ?? false
|
'disableUpload' => $perms['disableUpload'] ?? false
|
||||||
];
|
];
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
$tokFile,
|
$tokFile,
|
||||||
@@ -957,17 +921,16 @@ class UserController
|
|||||||
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
|
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Finalize login into session exactly as finalizeLogin() would ===
|
// Finalize login
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$_SESSION['authenticated'] = true;
|
$_SESSION['authenticated'] = true;
|
||||||
$_SESSION['username'] = $username;
|
$_SESSION['username'] = $username;
|
||||||
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
|
$_SESSION['isAdmin'] = ((int)UserModel::getUserRole($username) === 1);
|
||||||
$perms = loadUserPermissions($username);
|
$perms = loadUserPermissions($username);
|
||||||
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
||||||
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
||||||
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||||||
|
|
||||||
// Clean up pending markers
|
|
||||||
unset(
|
unset(
|
||||||
$_SESSION['pending_login_user'],
|
$_SESSION['pending_login_user'],
|
||||||
$_SESSION['pending_login_secret'],
|
$_SESSION['pending_login_secret'],
|
||||||
@@ -975,7 +938,6 @@ class UserController
|
|||||||
$_SESSION['totp_failures']
|
$_SESSION['totp_failures']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send back full login payload
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'status' => 'ok',
|
'status' => 'ok',
|
||||||
'success' => 'Login successful',
|
'success' => 'Login successful',
|
||||||
@@ -990,13 +952,13 @@ class UserController
|
|||||||
|
|
||||||
// Setup/verification flow (not pending)
|
// Setup/verification flow (not pending)
|
||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
if (!$username) {
|
if ($username === '') {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
|
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$totpSecret = userModel::getTOTPSecret($username);
|
$totpSecret = UserModel::getTOTPSecret($username);
|
||||||
if (!$totpSecret) {
|
if (!$totpSecret) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
|
echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
|
||||||
@@ -1010,34 +972,22 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful setup/verification
|
|
||||||
unset($_SESSION['totp_failures']);
|
unset($_SESSION['totp_failures']);
|
||||||
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
|
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload profile picture (multipart/form-data)
|
||||||
|
*/
|
||||||
public function uploadPicture()
|
public function uploadPicture()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
self::jsonHeaders();
|
||||||
|
|
||||||
// 1) Auth check
|
// Auth & CSRF
|
||||||
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
self::requireAuth();
|
||||||
http_response_code(401);
|
self::requireCsrf();
|
||||||
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) CSRF check
|
|
||||||
$headers = function_exists('getallheaders')
|
|
||||||
? array_change_key_case(getallheaders(), CASE_LOWER)
|
|
||||||
: [];
|
|
||||||
$csrf = $headers['x-csrf-token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
|
||||||
if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) File presence
|
|
||||||
if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
|
if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
|
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
|
||||||
@@ -1045,7 +995,7 @@ class UserController
|
|||||||
}
|
}
|
||||||
$file = $_FILES['profile_picture'];
|
$file = $_FILES['profile_picture'];
|
||||||
|
|
||||||
// 4) Validate MIME & size
|
// Validate MIME & size
|
||||||
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
|
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
|
||||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||||
@@ -1061,32 +1011,29 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Destination under public/uploads/profile_pics
|
// Destination
|
||||||
$uploadDir = UPLOAD_DIR . '/profile_pics';
|
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
|
||||||
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
|
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
|
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) Move file
|
|
||||||
$ext = $allowed[$mime];
|
$ext = $allowed[$mime];
|
||||||
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']);
|
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']);
|
||||||
$filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
$filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||||
$dest = "$uploadDir/$filename";
|
$dest = $uploadDir . '/' . $filename;
|
||||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
|
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7) Build public URL
|
// Assuming /uploads maps to UPLOAD_DIR publicly
|
||||||
$url = '/uploads/profile_pics/' . $filename;
|
$url = '/uploads/profile_pics/' . $filename;
|
||||||
|
|
||||||
// ─── THIS IS WHERE WE PERSIST INTO users.txt ───
|
|
||||||
$result = UserModel::setProfilePicture($_SESSION['username'], $url);
|
$result = UserModel::setProfilePicture($_SESSION['username'], $url);
|
||||||
if (!$result['success']) {
|
if (!($result['success'] ?? false)) {
|
||||||
// on failure, remove the file we just wrote
|
|
||||||
@unlink($dest);
|
@unlink($dest);
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
@@ -1095,9 +1042,7 @@ class UserController
|
|||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
// ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// 8) Return success
|
|
||||||
echo json_encode(['success' => true, 'url' => $url]);
|
echo json_encode(['success' => true, 'url' => $url]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
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,21 +233,25 @@ 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.
|
|
||||||
|
// No config on disk; return defaults.
|
||||||
return [
|
return [
|
||||||
'header_title' => "FileRise",
|
'header_title' => "FileRise",
|
||||||
'oidc' => [
|
'oidc' => [
|
||||||
@@ -193,7 +263,7 @@ class AdminModel
|
|||||||
'loginOptions' => [
|
'loginOptions' => [
|
||||||
'disableFormLogin' => false,
|
'disableFormLogin' => false,
|
||||||
'disableBasicAuth' => false,
|
'disableBasicAuth' => false,
|
||||||
'disableOIDCLogin' => false
|
'disableOIDCLogin' => true
|
||||||
],
|
],
|
||||||
'globalOtpauthUrl' => "",
|
'globalOtpauthUrl' => "",
|
||||||
'enableWebDAV' => false,
|
'enableWebDAV' => false,
|
||||||
@@ -201,4 +271,3 @@ class AdminModel
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,9 +2,46 @@
|
|||||||
// src/models/FileModel.php
|
// src/models/FileModel.php
|
||||||
|
|
||||||
require_once PROJECT_ROOT . '/config/config.php';
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
require_once __DIR__ . '/../../src/lib/ACL.php';
|
||||||
|
|
||||||
class FileModel {
|
class FileModel {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a logical folder key (e.g. "root", "invoices/2025") to a
|
||||||
|
* real path under UPLOAD_DIR, enforce REGEX_FOLDER_NAME, and ensure
|
||||||
|
* optional creation.
|
||||||
|
*
|
||||||
|
* @param string $folder
|
||||||
|
* @param bool $create
|
||||||
|
* @return array [string|null $realPath, string|null $error]
|
||||||
|
*/
|
||||||
|
private static function resolveFolderPath(string $folder, bool $create = true): array {
|
||||||
|
$folder = trim($folder) ?: 'root';
|
||||||
|
|
||||||
|
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||||
|
return [null, "Invalid folder name."];
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = realpath(UPLOAD_DIR);
|
||||||
|
if ($base === false) {
|
||||||
|
return [null, "Server misconfiguration."];
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = (strtolower($folder) === 'root')
|
||||||
|
? $base
|
||||||
|
: $base . DIRECTORY_SEPARATOR . trim($folder, "/\\ ");
|
||||||
|
|
||||||
|
if ($create && !is_dir($dir) && !mkdir($dir, 0775, true)) {
|
||||||
|
return [null, "Cannot create destination folder"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$real = realpath($dir);
|
||||||
|
if ($real === false || strpos($real, $base) !== 0) {
|
||||||
|
return [null, "Invalid folder path."];
|
||||||
|
}
|
||||||
|
return [$real, null];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copies files from a source folder to a destination folder, updating metadata if available.
|
* Copies files from a source folder to a destination folder, updating metadata if available.
|
||||||
*
|
*
|
||||||
@@ -15,30 +52,30 @@ class FileModel {
|
|||||||
*/
|
*/
|
||||||
public static function copyFiles($sourceFolder, $destinationFolder, $files) {
|
public static function copyFiles($sourceFolder, $destinationFolder, $files) {
|
||||||
$errors = [];
|
$errors = [];
|
||||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
|
||||||
|
|
||||||
// Build source and destination directories.
|
list($sourceDir, $err) = self::resolveFolderPath($sourceFolder, false);
|
||||||
$sourceDir = ($sourceFolder === 'root')
|
if ($err) return ["error" => $err];
|
||||||
? $baseDir . DIRECTORY_SEPARATOR
|
list($destDir, $err) = self::resolveFolderPath($destinationFolder, true);
|
||||||
: $baseDir . DIRECTORY_SEPARATOR . trim($sourceFolder, "/\\ ") . DIRECTORY_SEPARATOR;
|
if ($err) return ["error" => $err];
|
||||||
$destDir = ($destinationFolder === 'root')
|
|
||||||
? $baseDir . DIRECTORY_SEPARATOR
|
|
||||||
: $baseDir . DIRECTORY_SEPARATOR . trim($destinationFolder, "/\\ ") . DIRECTORY_SEPARATOR;
|
|
||||||
|
|
||||||
// Get metadata file paths.
|
$sourceDir .= DIRECTORY_SEPARATOR;
|
||||||
|
$destDir .= DIRECTORY_SEPARATOR;
|
||||||
|
|
||||||
|
// Metadata paths
|
||||||
$srcMetaFile = self::getMetadataFilePath($sourceFolder);
|
$srcMetaFile = self::getMetadataFilePath($sourceFolder);
|
||||||
$destMetaFile = self::getMetadataFilePath($destinationFolder);
|
$destMetaFile = self::getMetadataFilePath($destinationFolder);
|
||||||
|
|
||||||
$srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : [];
|
$srcMetadata = file_exists($srcMetaFile) ? (json_decode(file_get_contents($srcMetaFile), true) ?: []) : [];
|
||||||
$destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : [];
|
$destMetadata = file_exists($destMetaFile) ? (json_decode(file_get_contents($destMetaFile), true) ?: []) : [];
|
||||||
|
|
||||||
// Define a safe file name pattern.
|
|
||||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||||
|
$actor = $_SESSION['username'] ?? 'Unknown';
|
||||||
|
$now = date(DATE_TIME_FORMAT);
|
||||||
|
|
||||||
foreach ($files as $fileName) {
|
foreach ($files as $fileName) {
|
||||||
// Get the clean file name.
|
|
||||||
$originalName = basename(trim($fileName));
|
$originalName = basename(trim($fileName));
|
||||||
$basename = $originalName;
|
$basename = $originalName;
|
||||||
|
|
||||||
if (!preg_match($safeFileNamePattern, $basename)) {
|
if (!preg_match($safeFileNamePattern, $basename)) {
|
||||||
$errors[] = "$basename has an invalid name.";
|
$errors[] = "$basename has an invalid name.";
|
||||||
continue;
|
continue;
|
||||||
@@ -53,11 +90,10 @@ class FileModel {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a file with the same name exists at the destination, create a unique name.
|
// Avoid overwrite: pick unique name
|
||||||
if (file_exists($destPath)) {
|
if (file_exists($destPath)) {
|
||||||
$uniqueName = self::getUniqueFileName($destDir, $basename);
|
$basename = self::getUniqueFileName($destDir, $basename);
|
||||||
$basename = $uniqueName;
|
$destPath = $destDir . $basename;
|
||||||
$destPath = $destDir . $uniqueName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!copy($srcPath, $destPath)) {
|
if (!copy($srcPath, $destPath)) {
|
||||||
@@ -65,21 +101,27 @@ class FileModel {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update destination metadata if metadata exists in source.
|
// Carry over non-ownership fields (e.g., tags), but stamp new ownership/timestamps
|
||||||
if (isset($srcMetadata[$originalName])) {
|
$tags = [];
|
||||||
$destMetadata[$basename] = $srcMetadata[$originalName];
|
if (isset($srcMetadata[$originalName]['tags']) && is_array($srcMetadata[$originalName]['tags'])) {
|
||||||
}
|
$tags = $srcMetadata[$originalName]['tags'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) {
|
$destMetadata[$basename] = [
|
||||||
|
'uploaded' => $now,
|
||||||
|
'modified' => $now,
|
||||||
|
'uploader' => $actor,
|
||||||
|
'tags' => $tags
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||||
$errors[] = "Failed to update destination metadata.";
|
$errors[] = "Failed to update destination metadata.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($errors)) {
|
return empty($errors)
|
||||||
return ["success" => "Files copied successfully"];
|
? ["success" => "Files copied successfully"]
|
||||||
} else {
|
: ["error" => implode("; ", $errors)];
|
||||||
return ["error" => implode("; ", $errors)];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,12 +172,10 @@ class FileModel {
|
|||||||
*/
|
*/
|
||||||
public static function deleteFiles($folder, $files) {
|
public static function deleteFiles($folder, $files) {
|
||||||
$errors = [];
|
$errors = [];
|
||||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
|
||||||
|
|
||||||
// Determine the upload directory.
|
list($uploadDir, $err) = self::resolveFolderPath($folder, false);
|
||||||
$uploadDir = ($folder === 'root')
|
if ($err) return ["error" => $err];
|
||||||
? $baseDir . DIRECTORY_SEPARATOR
|
$uploadDir .= DIRECTORY_SEPARATOR;
|
||||||
: $baseDir . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR;
|
|
||||||
|
|
||||||
// Setup the Trash folder and metadata.
|
// Setup the Trash folder and metadata.
|
||||||
$trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
$trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
@@ -160,7 +200,6 @@ class FileModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$movedFiles = [];
|
$movedFiles = [];
|
||||||
// Define a safe file name pattern.
|
|
||||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||||
|
|
||||||
foreach ($files as $fileName) {
|
foreach ($files as $fileName) {
|
||||||
@@ -176,9 +215,8 @@ class FileModel {
|
|||||||
|
|
||||||
// Check if file exists.
|
// Check if file exists.
|
||||||
if (file_exists($filePath)) {
|
if (file_exists($filePath)) {
|
||||||
// Append a timestamp to create a unique trash file name.
|
// Unique trash name (timestamp + random)
|
||||||
$timestamp = time();
|
$trashFileName = $basename . '_' . time() . '_' . bin2hex(random_bytes(4));
|
||||||
$trashFileName = $basename . "_" . $timestamp;
|
|
||||||
if (rename($filePath, $trashDir . $trashFileName)) {
|
if (rename($filePath, $trashDir . $trashFileName)) {
|
||||||
$movedFiles[] = $basename;
|
$movedFiles[] = $basename;
|
||||||
// Record trash metadata for possible restoration.
|
// Record trash metadata for possible restoration.
|
||||||
@@ -187,11 +225,9 @@ class FileModel {
|
|||||||
'originalFolder' => $uploadDir,
|
'originalFolder' => $uploadDir,
|
||||||
'originalName' => $basename,
|
'originalName' => $basename,
|
||||||
'trashName' => $trashFileName,
|
'trashName' => $trashFileName,
|
||||||
'trashedAt' => $timestamp,
|
'trashedAt' => time(),
|
||||||
'uploaded' => isset($folderMetadata[$basename]['uploaded'])
|
'uploaded' => $folderMetadata[$basename]['uploaded'] ?? "Unknown",
|
||||||
? $folderMetadata[$basename]['uploaded'] : "Unknown",
|
'uploader' => $folderMetadata[$basename]['uploader'] ?? "Unknown",
|
||||||
'uploader' => isset($folderMetadata[$basename]['uploader'])
|
|
||||||
? $folderMetadata[$basename]['uploader'] : "Unknown",
|
|
||||||
'deletedBy' => $_SESSION['username'] ?? "Unknown"
|
'deletedBy' => $_SESSION['username'] ?? "Unknown"
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
@@ -205,7 +241,7 @@ class FileModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save updated trash metadata.
|
// Save updated trash metadata.
|
||||||
file_put_contents($trashMetadataFile, json_encode($trashData, JSON_PRETTY_PRINT));
|
file_put_contents($trashMetadataFile, json_encode($trashData, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
|
||||||
// Remove deleted file entries from folder metadata.
|
// Remove deleted file entries from folder metadata.
|
||||||
if (file_exists($metadataFile)) {
|
if (file_exists($metadataFile)) {
|
||||||
@@ -216,7 +252,7 @@ class FileModel {
|
|||||||
unset($metadata[$delFile]);
|
unset($metadata[$delFile]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT));
|
file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,22 +273,14 @@ class FileModel {
|
|||||||
*/
|
*/
|
||||||
public static function moveFiles($sourceFolder, $destinationFolder, $files) {
|
public static function moveFiles($sourceFolder, $destinationFolder, $files) {
|
||||||
$errors = [];
|
$errors = [];
|
||||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
|
||||||
|
|
||||||
// Build source and destination directories.
|
list($sourceDir, $err) = self::resolveFolderPath($sourceFolder, false);
|
||||||
$sourceDir = ($sourceFolder === 'root')
|
if ($err) return ["error" => $err];
|
||||||
? $baseDir . DIRECTORY_SEPARATOR
|
list($destDir, $err) = self::resolveFolderPath($destinationFolder, true);
|
||||||
: $baseDir . DIRECTORY_SEPARATOR . trim($sourceFolder, "/\\ ") . DIRECTORY_SEPARATOR;
|
if ($err) return ["error" => $err];
|
||||||
$destDir = ($destinationFolder === 'root')
|
|
||||||
? $baseDir . DIRECTORY_SEPARATOR
|
|
||||||
: $baseDir . DIRECTORY_SEPARATOR . trim($destinationFolder, "/\\ ") . DIRECTORY_SEPARATOR;
|
|
||||||
|
|
||||||
// Ensure destination directory exists.
|
$sourceDir .= DIRECTORY_SEPARATOR;
|
||||||
if (!is_dir($destDir)) {
|
$destDir .= DIRECTORY_SEPARATOR;
|
||||||
if (!mkdir($destDir, 0775, true)) {
|
|
||||||
return ["error" => "Could not create destination folder"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get metadata file paths.
|
// Get metadata file paths.
|
||||||
$srcMetaFile = self::getMetadataFilePath($sourceFolder);
|
$srcMetaFile = self::getMetadataFilePath($sourceFolder);
|
||||||
@@ -268,7 +296,6 @@ class FileModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$movedFiles = [];
|
$movedFiles = [];
|
||||||
// Define a safe file name pattern.
|
|
||||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||||
|
|
||||||
foreach ($files as $fileName) {
|
foreach ($files as $fileName) {
|
||||||
@@ -312,10 +339,10 @@ class FileModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write back updated metadata.
|
// Write back updated metadata.
|
||||||
if (file_put_contents($srcMetaFile, json_encode($srcMetadata, JSON_PRETTY_PRINT)) === false) {
|
if (file_put_contents($srcMetaFile, json_encode($srcMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||||
$errors[] = "Failed to update source metadata.";
|
$errors[] = "Failed to update source metadata.";
|
||||||
}
|
}
|
||||||
if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) {
|
if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||||
$errors[] = "Failed to update destination metadata.";
|
$errors[] = "Failed to update destination metadata.";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,10 +362,9 @@ class FileModel {
|
|||||||
* @return array An associative array with either "success" (and newName) or "error" message.
|
* @return array An associative array with either "success" (and newName) or "error" message.
|
||||||
*/
|
*/
|
||||||
public static function renameFile($folder, $oldName, $newName) {
|
public static function renameFile($folder, $oldName, $newName) {
|
||||||
// Determine the directory path.
|
list($directory, $err) = self::resolveFolderPath($folder, false);
|
||||||
$directory = ($folder !== 'root')
|
if ($err) return ["error" => $err];
|
||||||
? rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR
|
$directory .= DIRECTORY_SEPARATOR;
|
||||||
: UPLOAD_DIR;
|
|
||||||
|
|
||||||
// Sanitize file names.
|
// Sanitize file names.
|
||||||
$oldName = basename(trim($oldName));
|
$oldName = basename(trim($oldName));
|
||||||
@@ -374,7 +400,7 @@ class FileModel {
|
|||||||
if (isset($metadata[$oldName])) {
|
if (isset($metadata[$oldName])) {
|
||||||
$metadata[$newName] = $metadata[$oldName];
|
$metadata[$newName] = $metadata[$oldName];
|
||||||
unset($metadata[$oldName]);
|
unset($metadata[$oldName]);
|
||||||
file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT));
|
file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ["success" => "File renamed successfully", "newName" => $newName];
|
return ["success" => "File renamed successfully", "newName" => $newName];
|
||||||
@@ -393,66 +419,57 @@ class FileModel {
|
|||||||
* @return array ["success"=>"…"] or ["error"=>"…"]
|
* @return array ["success"=>"…"] or ["error"=>"…"]
|
||||||
*/
|
*/
|
||||||
public static function saveFile(string $folder, string $fileName, $content, ?string $uploader = null): array {
|
public static function saveFile(string $folder, string $fileName, $content, ?string $uploader = null): array {
|
||||||
// Sanitize inputs
|
|
||||||
$folder = trim($folder) ?: 'root';
|
$folder = trim($folder) ?: 'root';
|
||||||
$fileName = basename(trim($fileName));
|
$fileName = basename(trim($fileName));
|
||||||
|
|
||||||
// Validate folder name
|
|
||||||
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||||
return ["error" => "Invalid folder name"];
|
return ["error" => "Invalid folder name"];
|
||||||
}
|
}
|
||||||
|
if (!preg_match(REGEX_FILE_NAME, $fileName)) {
|
||||||
// Determine target directory
|
return ["error" => "Invalid file name"];
|
||||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
|
||||||
$targetDir = strtolower($folder) === 'root'
|
|
||||||
? $baseDir . DIRECTORY_SEPARATOR
|
|
||||||
: $baseDir . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR;
|
|
||||||
|
|
||||||
// Security check
|
|
||||||
if (strpos(realpath($targetDir), realpath($baseDir)) !== 0) {
|
|
||||||
return ["error" => "Invalid folder path"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure directory exists
|
$baseDirReal = realpath(UPLOAD_DIR);
|
||||||
|
if ($baseDirReal === false) {
|
||||||
|
return ["error" => "Server misconfiguration"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetDir = (strtolower($folder) === 'root')
|
||||||
|
? rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
: rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR;
|
||||||
|
|
||||||
|
// Ensure directory exists *before* realpath + containment check
|
||||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
||||||
return ["error" => "Failed to create destination folder"];
|
return ["error" => "Failed to create destination folder"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$filePath = $targetDir . $fileName;
|
$targetDirReal = realpath($targetDir);
|
||||||
|
if ($targetDirReal === false || strpos($targetDirReal, $baseDirReal) !== 0) {
|
||||||
|
return ["error" => "Invalid folder path"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = $targetDirReal . DIRECTORY_SEPARATOR . $fileName;
|
||||||
|
|
||||||
// ——— STREAM TO DISK ———
|
|
||||||
if (is_resource($content)) {
|
if (is_resource($content)) {
|
||||||
$out = fopen($filePath, 'wb');
|
$out = fopen($filePath, 'wb');
|
||||||
if ($out === false) {
|
if ($out === false) return ["error" => "Unable to open file for writing"];
|
||||||
return ["error" => "Unable to open file for writing"];
|
|
||||||
}
|
|
||||||
stream_copy_to_stream($content, $out);
|
stream_copy_to_stream($content, $out);
|
||||||
fclose($out);
|
fclose($out);
|
||||||
} else {
|
} else {
|
||||||
if (file_put_contents($filePath, (string)$content) === false) {
|
if (file_put_contents($filePath, (string)$content, LOCK_EX) === false) {
|
||||||
return ["error" => "Error saving file"];
|
return ["error" => "Error saving file"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ——— UPDATE METADATA ———
|
// Metadata
|
||||||
$metadataKey = strtolower($folder) === "root" ? "root" : $folder;
|
$metadataKey = strtolower($folder) === "root" ? "root" : $folder;
|
||||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', trim($metadataKey)) . '_metadata.json';
|
$metadataFileName = str_replace(['/', '\\', ' '], '-', trim($metadataKey)) . '_metadata.json';
|
||||||
$metadataFilePath = META_DIR . $metadataFileName;
|
$metadataFilePath = META_DIR . $metadataFileName;
|
||||||
|
|
||||||
// Load existing metadata
|
$metadata = file_exists($metadataFilePath) ? (json_decode(file_get_contents($metadataFilePath), true) ?: []) : [];
|
||||||
$metadata = [];
|
|
||||||
if (file_exists($metadataFilePath)) {
|
|
||||||
$existing = @json_decode(file_get_contents($metadataFilePath), true);
|
|
||||||
if (is_array($existing)) {
|
|
||||||
$metadata = $existing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentTime = date(DATE_TIME_FORMAT);
|
$currentTime = date(DATE_TIME_FORMAT);
|
||||||
// Use passed-in uploader, or fall back to session
|
$uploader = $uploader ?? ($_SESSION['username'] ?? "Unknown");
|
||||||
if ($uploader === null) {
|
|
||||||
$uploader = $_SESSION['username'] ?? "Unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($metadata[$fileName])) {
|
if (isset($metadata[$fileName])) {
|
||||||
$metadata[$fileName]['modified'] = $currentTime;
|
$metadata[$fileName]['modified'] = $currentTime;
|
||||||
@@ -465,7 +482,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file_put_contents($metadataFilePath, json_encode($metadata, JSON_PRETTY_PRINT)) === false) {
|
if (file_put_contents($metadataFilePath, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||||
return ["error" => "Failed to update metadata"];
|
return ["error" => "Failed to update metadata"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,8 +537,12 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
return ["error" => "File not found."];
|
return ["error" => "File not found."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the MIME type.
|
// Get the MIME type with safe fallback.
|
||||||
$mimeType = mime_content_type($realFilePath);
|
$mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null;
|
||||||
|
if (!$mimeType) {
|
||||||
|
$mimeType = 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"filePath" => $realFilePath,
|
"filePath" => $realFilePath,
|
||||||
"mimeType" => $mimeType
|
"mimeType" => $mimeType
|
||||||
@@ -603,18 +624,18 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
$allSuccess = true;
|
$allSuccess = true;
|
||||||
$extractedFiles = [];
|
$extractedFiles = [];
|
||||||
|
|
||||||
// Determine the base upload directory and build the folder path.
|
|
||||||
$baseDir = realpath(UPLOAD_DIR);
|
$baseDir = realpath(UPLOAD_DIR);
|
||||||
if ($baseDir === false) {
|
if ($baseDir === false) {
|
||||||
return ["error" => "Uploads directory not configured correctly."];
|
return ["error" => "Uploads directory not configured correctly."];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strtolower($folder) === "root" || trim($folder) === "") {
|
// Build target dir
|
||||||
|
if (strtolower(trim($folder) ?: '') === "root") {
|
||||||
$relativePath = "";
|
$relativePath = "";
|
||||||
} else {
|
} else {
|
||||||
$parts = explode('/', trim($folder, "/\\"));
|
$parts = explode('/', trim($folder, "/\\"));
|
||||||
foreach ($parts as $part) {
|
foreach ($parts as $part) {
|
||||||
if (empty($part) || $part === '.' || $part === '..' || !preg_match(REGEX_FOLDER_NAME, $part)) {
|
if ($part === '' || $part === '.' || $part === '..' || !preg_match(REGEX_FOLDER_NAME, $part)) {
|
||||||
return ["error" => "Invalid folder name."];
|
return ["error" => "Invalid folder name."];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -622,90 +643,105 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
}
|
}
|
||||||
|
|
||||||
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath;
|
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath;
|
||||||
|
if (!is_dir($folderPath) && !mkdir($folderPath, 0775, true)) {
|
||||||
|
return ["error" => "Folder not found and cannot be created."];
|
||||||
|
}
|
||||||
$folderPathReal = realpath($folderPath);
|
$folderPathReal = realpath($folderPath);
|
||||||
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
|
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
|
||||||
return ["error" => "Folder not found."];
|
return ["error" => "Folder not found."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare metadata.
|
// Prepare metadata container
|
||||||
// Reuse our helper method if available; otherwise, re-create the logic.
|
|
||||||
$metadataFile = self::getMetadataFilePath($folder);
|
$metadataFile = self::getMetadataFilePath($folder);
|
||||||
$srcMetadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
|
$destMetadata = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
|
||||||
if (!is_array($srcMetadata)) {
|
|
||||||
$srcMetadata = [];
|
|
||||||
}
|
|
||||||
// For simplicity, we update the same metadata file after extraction.
|
|
||||||
$destMetadata = $srcMetadata;
|
|
||||||
|
|
||||||
// Define a safe file name pattern.
|
|
||||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||||
|
$actor = $_SESSION['username'] ?? 'Unknown';
|
||||||
|
$now = date(DATE_TIME_FORMAT);
|
||||||
|
|
||||||
// Process each ZIP file.
|
|
||||||
foreach ($files as $zipFileName) {
|
foreach ($files as $zipFileName) {
|
||||||
$originalName = basename(trim($zipFileName));
|
$zipBase = basename(trim($zipFileName));
|
||||||
// Process only .zip files.
|
if (strtolower(substr($zipBase, -4)) !== '.zip') {
|
||||||
if (strtolower(substr($originalName, -4)) !== '.zip') {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!preg_match($safeFileNamePattern, $originalName)) {
|
if (!preg_match($safeFileNamePattern, $zipBase)) {
|
||||||
$errors[] = "$originalName has an invalid name.";
|
$errors[] = "$zipBase has an invalid name.";
|
||||||
$allSuccess = false;
|
$allSuccess = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$zipFilePath = $folderPathReal . DIRECTORY_SEPARATOR . $originalName;
|
$zipFilePath = $folderPathReal . DIRECTORY_SEPARATOR . $zipBase;
|
||||||
if (!file_exists($zipFilePath)) {
|
if (!file_exists($zipFilePath)) {
|
||||||
$errors[] = "$originalName does not exist in folder.";
|
$errors[] = "$zipBase does not exist in folder.";
|
||||||
$allSuccess = false;
|
$allSuccess = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$zip = new ZipArchive();
|
$zip = new ZipArchive();
|
||||||
if ($zip->open($zipFilePath) !== TRUE) {
|
if ($zip->open($zipFilePath) !== TRUE) {
|
||||||
$errors[] = "Could not open $originalName as a zip file.";
|
$errors[] = "Could not open $zipBase as a zip file.";
|
||||||
$allSuccess = false;
|
$allSuccess = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt extraction.
|
// Minimal Zip Slip guard: fail if any entry looks unsafe
|
||||||
if (!$zip->extractTo($folderPathReal)) {
|
$unsafe = false;
|
||||||
$errors[] = "Failed to extract $originalName.";
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$entryName = $zip->getNameIndex($i);
|
||||||
|
if ($entryName === false) { $unsafe = true; break; }
|
||||||
|
// Absolute paths, parent traversal, or Windows drive paths
|
||||||
|
if (strpos($entryName, '../') !== false || strpos($entryName, '..\\') !== false ||
|
||||||
|
str_starts_with($entryName, '/') || preg_match('/^[A-Za-z]:[\\\\\\/]/', $entryName)) {
|
||||||
|
$unsafe = true; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($unsafe) {
|
||||||
|
$zip->close();
|
||||||
|
$errors[] = "$zipBase contains unsafe paths; extraction aborted.";
|
||||||
$allSuccess = false;
|
$allSuccess = false;
|
||||||
} else {
|
continue;
|
||||||
// Collect extracted file names from this archive.
|
}
|
||||||
|
|
||||||
|
// Extract safely (whole archive) after precheck
|
||||||
|
if (!$zip->extractTo($folderPathReal)) {
|
||||||
|
$errors[] = "Failed to extract $zipBase.";
|
||||||
|
$allSuccess = false;
|
||||||
|
$zip->close();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp metadata for extracted regular files
|
||||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
$entryName = $zip->getNameIndex($i);
|
$entryName = $zip->getNameIndex($i);
|
||||||
$extractedFileName = basename($entryName);
|
if ($entryName === false) continue;
|
||||||
if ($extractedFileName) {
|
|
||||||
$extractedFiles[] = $extractedFileName;
|
$basename = basename($entryName);
|
||||||
}
|
if ($basename === '' || !preg_match($safeFileNamePattern, $basename)) continue;
|
||||||
}
|
|
||||||
// Update metadata for each extracted file if the ZIP has metadata.
|
// Only stamp files that actually exist after extraction
|
||||||
if (isset($srcMetadata[$originalName])) {
|
$target = $folderPathReal . DIRECTORY_SEPARATOR . $entryName;
|
||||||
$zipMeta = $srcMetadata[$originalName];
|
$isDir = str_ends_with($entryName, '/') || is_dir($target);
|
||||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
if ($isDir) continue;
|
||||||
$entryName = $zip->getNameIndex($i);
|
|
||||||
$extractedFileName = basename($entryName);
|
$extractedFiles[] = $basename;
|
||||||
if ($extractedFileName) {
|
$destMetadata[$basename] = [
|
||||||
$destMetadata[$extractedFileName] = $zipMeta;
|
'uploaded' => $now,
|
||||||
}
|
'modified' => $now,
|
||||||
}
|
'uploader' => $actor,
|
||||||
}
|
// no tags by default
|
||||||
|
];
|
||||||
}
|
}
|
||||||
$zip->close();
|
$zip->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save updated metadata.
|
if (file_put_contents($metadataFile, json_encode($destMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||||
if (file_put_contents($metadataFile, json_encode($destMetadata, JSON_PRETTY_PRINT)) === false) {
|
|
||||||
$errors[] = "Failed to update metadata.";
|
$errors[] = "Failed to update metadata.";
|
||||||
$allSuccess = false;
|
$allSuccess = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($allSuccess) {
|
return $allSuccess
|
||||||
return ["success" => true, "extractedFiles" => $extractedFiles];
|
? ["success" => true, "extractedFiles" => $extractedFiles]
|
||||||
} else {
|
: ["success" => false, "error" => implode(" ", $errors)];
|
||||||
return ["success" => false, "error" => implode(" ", $errors)];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -731,7 +767,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
*
|
*
|
||||||
* @param string $folder The folder containing the shared file (or "root").
|
* @param string $folder The folder containing the shared file (or "root").
|
||||||
* @param string $file The name of the file being shared.
|
* @param string $file The name of the file being shared.
|
||||||
* @param int $expirationMinutes The number of minutes until expiration.
|
* @param int $expirationSeconds The number of seconds until expiration.
|
||||||
* @param string $password Optional password protecting the share.
|
* @param string $password Optional password protecting the share.
|
||||||
* @return array Returns an associative array with keys "token" and "expires" on success,
|
* @return array Returns an associative array with keys "token" and "expires" on success,
|
||||||
* or "error" on failure.
|
* or "error" on failure.
|
||||||
@@ -741,6 +777,11 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||||
return ["error" => "Invalid folder name."];
|
return ["error" => "Invalid folder name."];
|
||||||
}
|
}
|
||||||
|
// Validate file name.
|
||||||
|
$file = basename(trim($file));
|
||||||
|
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||||
|
return ["error" => "Invalid file name."];
|
||||||
|
}
|
||||||
|
|
||||||
// Generate a secure token (32 hex characters).
|
// Generate a secure token (32 hex characters).
|
||||||
$token = bin2hex(random_bytes(16));
|
$token = bin2hex(random_bytes(16));
|
||||||
@@ -779,7 +820,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Save the updated share links.
|
// Save the updated share links.
|
||||||
if (file_put_contents($shareFile, json_encode($shareLinks, JSON_PRETTY_PRINT))) {
|
if (file_put_contents($shareFile, json_encode($shareLinks, JSON_PRETTY_PRINT), LOCK_EX)) {
|
||||||
return ["token" => $token, "expires" => $expires];
|
return ["token" => $token, "expires" => $expires];
|
||||||
} else {
|
} else {
|
||||||
return ["error" => "Could not save share link."];
|
return ["error" => "Could not save share link."];
|
||||||
@@ -963,7 +1004,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
"uploader" => isset($record['uploader']) ? $record['uploader'] : "Unknown"
|
"uploader" => isset($record['uploader']) ? $record['uploader'] : "Unknown"
|
||||||
];
|
];
|
||||||
$metadata[$originalName] = $restoredMeta;
|
$metadata[$originalName] = $restoredMeta;
|
||||||
file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT));
|
file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
unset($trashData[$recordKey]);
|
unset($trashData[$recordKey]);
|
||||||
} else {
|
} else {
|
||||||
$errors[] = "Failed to restore $originalName.";
|
$errors[] = "Failed to restore $originalName.";
|
||||||
@@ -974,7 +1015,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write back updated trash metadata.
|
// Write back updated trash metadata.
|
||||||
file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT));
|
file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
|
||||||
if (empty($errors)) {
|
if (empty($errors)) {
|
||||||
return ["success" => "Items restored: " . implode(", ", $restoredItems), "restored" => $restoredItems];
|
return ["success" => "Items restored: " . implode(", ", $restoredItems), "restored" => $restoredItems];
|
||||||
@@ -1045,7 +1086,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save the updated trash metadata back as an indexed array.
|
// Save the updated trash metadata back as an indexed array.
|
||||||
file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT));
|
file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
|
||||||
if (empty($errors)) {
|
if (empty($errors)) {
|
||||||
return ["deleted" => $deletedFiles];
|
return ["deleted" => $deletedFiles];
|
||||||
@@ -1095,14 +1136,20 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
* @return array Returns an associative array with a "success" key and updated "globalTags", or an "error" key on failure.
|
* @return array Returns an associative array with a "success" key and updated "globalTags", or an "error" key on failure.
|
||||||
*/
|
*/
|
||||||
public static function saveFileTag(string $folder, string $file, array $tags, bool $deleteGlobal = false, ?string $tagToDelete = null): array {
|
public static function saveFileTag(string $folder, string $file, array $tags, bool $deleteGlobal = false, ?string $tagToDelete = null): array {
|
||||||
// Determine the folder metadata file.
|
// Validate the file name and folder
|
||||||
$folder = trim($folder) ?: 'root';
|
$folder = trim($folder) ?: 'root';
|
||||||
$metadataFile = "";
|
$file = basename(trim($file));
|
||||||
if (strtolower($folder) === "root") {
|
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||||
$metadataFile = META_DIR . "root_metadata.json";
|
return ["error" => "Invalid folder name."];
|
||||||
} else {
|
|
||||||
$metadataFile = META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
|
|
||||||
}
|
}
|
||||||
|
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||||
|
return ["error" => "Invalid file name."];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the folder metadata file.
|
||||||
|
$metadataFile = (strtolower($folder) === "root")
|
||||||
|
? META_DIR . "root_metadata.json"
|
||||||
|
: META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
|
||||||
|
|
||||||
// Load existing metadata for this folder.
|
// Load existing metadata for this folder.
|
||||||
$metadata = [];
|
$metadata = [];
|
||||||
@@ -1116,7 +1163,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
}
|
}
|
||||||
$metadata[$file]['tags'] = $tags;
|
$metadata[$file]['tags'] = $tags;
|
||||||
|
|
||||||
if (file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT)) === false) {
|
if (file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||||
return ["error" => "Failed to save tag data for file metadata."];
|
return ["error" => "Failed to save tag data for file metadata."];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,9 +1198,10 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
$globalTags[] = $tag;
|
$globalTags[] = $tag;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
unset($globalTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file_put_contents($globalTagsFile, json_encode($globalTags, JSON_PRETTY_PRINT)) === false) {
|
if (file_put_contents($globalTagsFile, json_encode($globalTags, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||||
return ["error" => "Failed to save global tags."];
|
return ["error" => "Failed to save global tags."];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,7 +1215,12 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
* @return array Returns an associative array with keys "files" and "globalTags".
|
* @return array Returns an associative array with keys "files" and "globalTags".
|
||||||
*/
|
*/
|
||||||
public static function getFileList(string $folder): array {
|
public static function getFileList(string $folder): array {
|
||||||
|
// --- caps for safe inlining ---
|
||||||
|
if (!defined('LISTING_CONTENT_BYTES_MAX')) define('LISTING_CONTENT_BYTES_MAX', 8192); // 8 KB snippet
|
||||||
|
if (!defined('INDEX_TEXT_BYTES_MAX')) define('INDEX_TEXT_BYTES_MAX', 5 * 1024 * 1024); // only sample files ≤ 5 MB
|
||||||
|
|
||||||
$folder = trim($folder) ?: 'root';
|
$folder = trim($folder) ?: 'root';
|
||||||
|
|
||||||
// Determine the target directory.
|
// Determine the target directory.
|
||||||
if (strtolower($folder) !== 'root') {
|
if (strtolower($folder) !== 'root') {
|
||||||
$directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
$directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
||||||
@@ -1188,7 +1241,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
|
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
|
||||||
};
|
};
|
||||||
$metadataFile = $getMetadataFilePath($folder);
|
$metadataFile = $getMetadataFilePath($folder);
|
||||||
$metadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
|
$metadata = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
|
||||||
|
|
||||||
if (!is_dir($directory)) {
|
if (!is_dir($directory)) {
|
||||||
return ["error" => "Directory not found."];
|
return ["error" => "Directory not found."];
|
||||||
@@ -1200,9 +1253,12 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
// Define a safe file name pattern.
|
// Define a safe file name pattern.
|
||||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||||
|
|
||||||
|
// Prepare finfo (if available) for MIME sniffing.
|
||||||
|
$finfo = function_exists('finfo_open') ? @finfo_open(FILEINFO_MIME_TYPE) : false;
|
||||||
|
|
||||||
foreach ($allFiles as $file) {
|
foreach ($allFiles as $file) {
|
||||||
if (substr($file, 0, 1) === '.') {
|
if ($file === '' || $file[0] === '.') {
|
||||||
continue; // Skip hidden files.
|
continue; // Skip hidden/invalid entries.
|
||||||
}
|
}
|
||||||
|
|
||||||
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
||||||
@@ -1213,12 +1269,16 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$fileDateModified = filemtime($filePath) ? date(DATE_TIME_FORMAT, filemtime($filePath)) : "Unknown";
|
// Meta
|
||||||
|
$mtime = @filemtime($filePath);
|
||||||
|
$fileDateModified = $mtime ? date(DATE_TIME_FORMAT, $mtime) : "Unknown";
|
||||||
$metaKey = $file;
|
$metaKey = $file;
|
||||||
$fileUploadedDate = isset($metadata[$metaKey]["uploaded"]) ? $metadata[$metaKey]["uploaded"] : "Unknown";
|
$fileUploadedDate = isset($metadata[$metaKey]["uploaded"]) ? $metadata[$metaKey]["uploaded"] : "Unknown";
|
||||||
$fileUploader = isset($metadata[$metaKey]["uploader"]) ? $metadata[$metaKey]["uploader"] : "Unknown";
|
$fileUploader = isset($metadata[$metaKey]["uploader"]) ? $metadata[$metaKey]["uploader"] : "Unknown";
|
||||||
|
|
||||||
$fileSizeBytes = filesize($filePath);
|
// Size
|
||||||
|
$fileSizeBytes = @filesize($filePath);
|
||||||
|
if (!is_int($fileSizeBytes)) $fileSizeBytes = 0;
|
||||||
if ($fileSizeBytes >= 1073741824) {
|
if ($fileSizeBytes >= 1073741824) {
|
||||||
$fileSizeFormatted = sprintf("%.1f GB", $fileSizeBytes / 1073741824);
|
$fileSizeFormatted = sprintf("%.1f GB", $fileSizeBytes / 1073741824);
|
||||||
} elseif ($fileSizeBytes >= 1048576) {
|
} elseif ($fileSizeBytes >= 1048576) {
|
||||||
@@ -1229,27 +1289,63 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
$fileSizeFormatted = sprintf("%s bytes", number_format($fileSizeBytes));
|
$fileSizeFormatted = sprintf("%s bytes", number_format($fileSizeBytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MIME + text detection (fallback to extension)
|
||||||
|
$mime = 'application/octet-stream';
|
||||||
|
if ($finfo) {
|
||||||
|
$det = @finfo_file($finfo, $filePath);
|
||||||
|
if (is_string($det) && $det !== '') $mime = $det;
|
||||||
|
}
|
||||||
|
$isTextByMime = (strpos((string)$mime, 'text/') === 0) || $mime === 'application/json' || $mime === 'application/xml';
|
||||||
|
$isTextByExt = (bool)preg_match('/\.(txt|md|csv|json|xml|html?|css|js|log|ini|conf|config|yml|yaml|php|py|rb|sh|bat|ps1|ts|tsx|c|cpp|h|hpp|java|go|rs)$/i', $file);
|
||||||
|
$isText = $isTextByMime || $isTextByExt;
|
||||||
|
|
||||||
|
// Build entry
|
||||||
$fileEntry = [
|
$fileEntry = [
|
||||||
'name' => $file,
|
'name' => $file,
|
||||||
'modified' => $fileDateModified,
|
'modified' => $fileDateModified,
|
||||||
'uploaded' => $fileUploadedDate,
|
'uploaded' => $fileUploadedDate,
|
||||||
'size' => $fileSizeFormatted,
|
'size' => $fileSizeFormatted,
|
||||||
|
'sizeBytes' => $fileSizeBytes, // ← numeric size for frontend logic
|
||||||
'uploader' => $fileUploader,
|
'uploader' => $fileUploader,
|
||||||
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : []
|
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : [],
|
||||||
|
'mime' => $mime,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Optionally include file content for text-based files.
|
// Small, safe snippet for text files only (never full content)
|
||||||
if (preg_match('/\.(txt|html|htm|md|js|css|json|xml|php|py|ini|conf|log)$/i', $file)) {
|
$fileEntry['content'] = '';
|
||||||
$content = file_get_contents($filePath);
|
$fileEntry['contentTruncated'] = false;
|
||||||
$fileEntry['content'] = $content;
|
|
||||||
|
if ($isText && $fileSizeBytes > 0) {
|
||||||
|
if ($fileSizeBytes <= INDEX_TEXT_BYTES_MAX) {
|
||||||
|
$fh = @fopen($filePath, 'rb');
|
||||||
|
if ($fh) {
|
||||||
|
$snippet = @fread($fh, LISTING_CONTENT_BYTES_MAX);
|
||||||
|
@fclose($fh);
|
||||||
|
if ($snippet !== false) {
|
||||||
|
// ensure UTF-8 for JSON
|
||||||
|
if (function_exists('mb_check_encoding') && !mb_check_encoding($snippet, 'UTF-8')) {
|
||||||
|
if (function_exists('mb_convert_encoding')) {
|
||||||
|
$snippet = @mb_convert_encoding($snippet, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$fileEntry['content'] = $snippet;
|
||||||
|
$fileEntry['contentTruncated'] = ($fileSizeBytes > LISTING_CONTENT_BYTES_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// too large to sample: mark truncated so UI/search knows
|
||||||
|
$fileEntry['contentTruncated'] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$fileList[] = $fileEntry;
|
$fileList[] = $fileEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($finfo) { @finfo_close($finfo); }
|
||||||
|
|
||||||
// Load global tags.
|
// Load global tags.
|
||||||
$globalTagsFile = META_DIR . "createdTags.json";
|
$globalTagsFile = META_DIR . "createdTags.json";
|
||||||
$globalTags = file_exists($globalTagsFile) ? json_decode(file_get_contents($globalTagsFile), true) : [];
|
$globalTags = file_exists($globalTagsFile) ? (json_decode(file_get_contents($globalTagsFile), true) ?: []) : [];
|
||||||
|
|
||||||
return ["files" => $fileList, "globalTags" => $globalTags];
|
return ["files" => $fileList, "globalTags" => $globalTags];
|
||||||
}
|
}
|
||||||
@@ -1275,7 +1371,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1290,21 +1386,18 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
public static function createFile(string $folder, string $filename, string $uploader): array
|
public static function createFile(string $folder, string $filename, string $uploader): array
|
||||||
{
|
{
|
||||||
// 1) basic validation
|
// 1) basic validation
|
||||||
if (!preg_match('/^[\w\-. ]+$/', $filename)) {
|
$filename = basename(trim($filename));
|
||||||
|
if (!preg_match(REGEX_FILE_NAME, $filename)) {
|
||||||
return ['success'=>false,'error'=>'Invalid filename','code'=>400];
|
return ['success'=>false,'error'=>'Invalid filename','code'=>400];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) build target path
|
// 2) resolve target folder
|
||||||
$base = UPLOAD_DIR;
|
list($baseDir, $err) = self::resolveFolderPath($folder, true);
|
||||||
if ($folder !== 'root') {
|
if ($err) {
|
||||||
$base = rtrim(UPLOAD_DIR, '/\\')
|
return ['success'=>false, 'error'=>$err, 'code'=>($err === 'Invalid folder name.' ? 400 : 500)];
|
||||||
. DIRECTORY_SEPARATOR . $folder
|
|
||||||
. DIRECTORY_SEPARATOR;
|
|
||||||
}
|
}
|
||||||
if (!is_dir($base) && !mkdir($base, 0775, true)) {
|
|
||||||
return ['success'=>false,'error'=>'Cannot create folder','code'=>500];
|
$path = $baseDir . DIRECTORY_SEPARATOR . $filename;
|
||||||
}
|
|
||||||
$path = $base . $filename;
|
|
||||||
|
|
||||||
// 3) no overwrite
|
// 3) no overwrite
|
||||||
if (file_exists($path)) {
|
if (file_exists($path)) {
|
||||||
@@ -1312,12 +1405,12 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4) touch the file
|
// 4) touch the file
|
||||||
if (false === @file_put_contents($path, '')) {
|
if (false === @file_put_contents($path, '', LOCK_EX)) {
|
||||||
return ['success'=>false,'error'=>'Could not create file','code'=>500];
|
return ['success'=>false,'error'=>'Could not create file','code'=>500];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) write metadata
|
// 5) write metadata
|
||||||
$metaKey = ($folder === 'root') ? 'root' : $folder;
|
$metaKey = (strtolower($folder) === 'root' || trim($folder) === '') ? 'root' : $folder;
|
||||||
$metaName = str_replace(['/', '\\', ' '], '-', $metaKey) . '_metadata.json';
|
$metaName = str_replace(['/', '\\', ' '], '-', $metaKey) . '_metadata.json';
|
||||||
$metaPath = META_DIR . $metaName;
|
$metaPath = META_DIR . $metaName;
|
||||||
|
|
||||||
@@ -1327,12 +1420,14 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
|||||||
$collection = json_decode($json, true) ?: [];
|
$collection = json_decode($json, true) ?: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$now = date(DATE_TIME_FORMAT);
|
||||||
$collection[$filename] = [
|
$collection[$filename] = [
|
||||||
'uploaded' => date(DATE_TIME_FORMAT),
|
'uploaded' => $now,
|
||||||
|
'modified' => $now,
|
||||||
'uploader' => $uploader
|
'uploader' => $uploader
|
||||||
];
|
];
|
||||||
|
|
||||||
if (false === file_put_contents($metaPath, json_encode($collection, JSON_PRETTY_PRINT))) {
|
if (false === file_put_contents($metaPath, json_encode($collection, JSON_PRETTY_PRINT), LOCK_EX)) {
|
||||||
return ['success'=>false,'error'=>'Failed to update metadata','code'=>500];
|
return ['success'=>false,'error'=>'Failed to update metadata','code'=>500];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
// Validate folder name (only letters, numbers, underscores, dashes, and spaces allowed).
|
return is_array($json) ? $json : [];
|
||||||
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
|
||||||
return ["error" => "Invalid folder name."];
|
|
||||||
}
|
|
||||||
if ($parent !== "" && !preg_match(REGEX_FOLDER_NAME, $parent)) {
|
|
||||||
return ["error" => "Invalid parent folder name."];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
/** Persist the folder → owner map. */
|
||||||
if ($parent !== "" && strtolower($parent) !== "root") {
|
public static function saveFolderOwners(array $map): bool
|
||||||
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName;
|
{
|
||||||
$relativePath = $parent . "/" . $folderName;
|
return (bool) @file_put_contents(FOLDER_OWNERS_FILE, json_encode($map, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set (or replace) the owner for a specific folder (relative path or 'root'). */
|
||||||
|
public static function setOwnerFor(string $folder, string $owner): void
|
||||||
|
{
|
||||||
|
$key = trim($folder, "/\\ ");
|
||||||
|
$key = ($key === '' ? 'root' : $key);
|
||||||
|
$owners = self::getFolderOwners();
|
||||||
|
$owners[$key] = $owner;
|
||||||
|
self::saveFolderOwners($owners);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the owner for a folder (relative path or 'root'); returns null if unmapped. */
|
||||||
|
public static function getOwnerFor(string $folder): ?string
|
||||||
|
{
|
||||||
|
$key = trim($folder, "/\\ ");
|
||||||
|
$key = ($key === '' ? 'root' : $key);
|
||||||
|
$owners = self::getFolderOwners();
|
||||||
|
return $owners[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename a single ownership key (old → new). */
|
||||||
|
public static function renameOwnerKey(string $old, string $new): void
|
||||||
|
{
|
||||||
|
$old = trim($old, "/\\ ");
|
||||||
|
$new = trim($new, "/\\ ");
|
||||||
|
$owners = self::getFolderOwners();
|
||||||
|
if (isset($owners[$old])) {
|
||||||
|
$owners[$new] = $owners[$old];
|
||||||
|
unset($owners[$old]);
|
||||||
|
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 {
|
} else {
|
||||||
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $folderName;
|
$rebased[$k] = $v;
|
||||||
$relativePath = $folderName;
|
}
|
||||||
|
}
|
||||||
|
self::saveFolderOwners($rebased);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the folder already exists.
|
/* ============================================================
|
||||||
if (file_exists($fullPath)) {
|
* Existing helpers
|
||||||
return ["error" => "Folder already exists."];
|
* ============================================================ */
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to create the folder.
|
|
||||||
if (mkdir($fullPath, 0755, true)) {
|
|
||||||
// Create an empty metadata file for the new folder.
|
|
||||||
$metadataFile = self::getMetadataFilePath($relativePath);
|
|
||||||
if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT)) === false) {
|
|
||||||
return ["error" => "Folder created but failed to create metadata file."];
|
|
||||||
}
|
|
||||||
return ["success" => true];
|
|
||||||
} else {
|
|
||||||
return ["error" => "Failed to create folder."];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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) {
|
||||||
|
$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)."];
|
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,15 +334,18 @@ 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)) {
|
||||||
@@ -223,14 +358,8 @@ class FolderModel
|
|||||||
"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;
|
||||||
@@ -245,98 +374,80 @@ class FolderModel
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if ($it === '.' || $it === '..') continue;
|
||||||
|
if ($it[0] === '.') continue;
|
||||||
|
if (!preg_match(REGEX_FILE_NAME, $it)) continue;
|
||||||
|
if (is_file($realFolderPath . DIRECTORY_SEPARATOR . $it)) {
|
||||||
|
$allFiles[] = $it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($allFiles, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
|
||||||
$totalFiles = count($allFiles);
|
$totalFiles = count($allFiles);
|
||||||
$totalPages = max(1, ceil($totalFiles / $itemsPerPage));
|
$totalPages = max(1, (int)ceil($totalFiles / max(1, $itemsPerPage)));
|
||||||
$currentPage = min($page, $totalPages);
|
$currentPage = min(max(1, $page), $totalPages);
|
||||||
$startIndex = ($currentPage - 1) * $itemsPerPage;
|
$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,
|
||||||
@@ -346,40 +457,29 @@ class FolderModel
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 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;
|
|
||||||
|
|
||||||
// Password hash
|
|
||||||
$hashedPassword= $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
|
$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,23 +487,23 @@ 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')
|
||||||
|
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||||
|
$scheme = $https ? 'https' : 'http';
|
||||||
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
|
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
|
||||||
$baseUrl = $protocol . '://' . rtrim($host, '/');
|
$baseUrl = $scheme . '://' . rtrim($host, '/');
|
||||||
$link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token);
|
$link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token);
|
||||||
|
|
||||||
return ["token" => $token, "expires" => $expires, "link" => $link];
|
return ["token" => $token, "expires" => $expires, "link" => $link];
|
||||||
@@ -411,83 +511,54 @@ class FolderModel
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,7 @@ require_once PROJECT_ROOT . '/config/config.php';
|
|||||||
class userModel
|
class userModel
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Retrieves all users from the users file.
|
* Retrieve all users (username + role).
|
||||||
*
|
|
||||||
* @return array Returns an array of users.
|
|
||||||
*/
|
*/
|
||||||
public static function getAllUsers()
|
public static function getAllUsers()
|
||||||
{
|
{
|
||||||
@@ -30,75 +28,83 @@ class userModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a new user.
|
* Add a user.
|
||||||
*
|
*
|
||||||
* @param string $username The new username.
|
* @param string $username
|
||||||
* @param string $password The plain-text password.
|
* @param string $password
|
||||||
* @param string $isAdmin "1" if admin; "0" otherwise.
|
* @param string $isAdmin "1" or "0"
|
||||||
* @param bool $setupMode If true, overwrite the users file.
|
* @param bool $setupMode overwrite file if true
|
||||||
* @return array Response containing either an error or a success message.
|
|
||||||
*/
|
*/
|
||||||
public static function addUser($username, $password, $isAdmin, $setupMode)
|
public static function addUser($username, $password, $isAdmin, $setupMode)
|
||||||
{
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
|
||||||
// Ensure users.txt exists.
|
// Defense in depth
|
||||||
|
if (!preg_match(REGEX_USER, $username)) {
|
||||||
|
return ["error" => "Invalid username"];
|
||||||
|
}
|
||||||
|
if (!is_string($password) || $password === '') {
|
||||||
|
return ["error" => "Password required"];
|
||||||
|
}
|
||||||
|
$isAdmin = $isAdmin === '1' ? '1' : '0';
|
||||||
|
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
file_put_contents($usersFile, '');
|
@file_put_contents($usersFile, '', LOCK_EX);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if username already exists.
|
// Check duplicates
|
||||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||||
foreach ($existingUsers as $line) {
|
foreach ($existingUsers as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
if ($username === $parts[0]) {
|
if (isset($parts[0]) && $username === $parts[0]) {
|
||||||
return ["error" => "User already exists"];
|
return ["error" => "User already exists"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash the password.
|
|
||||||
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
|
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
|
||||||
|
|
||||||
// Prepare the new line.
|
|
||||||
$newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
|
$newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
|
||||||
|
|
||||||
// If setup mode, overwrite the file; otherwise, append.
|
|
||||||
if ($setupMode) {
|
if ($setupMode) {
|
||||||
file_put_contents($usersFile, $newUserLine);
|
if (file_put_contents($usersFile, $newUserLine, LOCK_EX) === false) {
|
||||||
|
return ["error" => "Failed to write users file"];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
file_put_contents($usersFile, $newUserLine, FILE_APPEND);
|
if (file_put_contents($usersFile, $newUserLine, FILE_APPEND | LOCK_EX) === false) {
|
||||||
|
return ["error" => "Failed to write users file"];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ["success" => "User added successfully"];
|
return ["success" => "User added successfully"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the specified user from the users file and updates the userPermissions file.
|
* Remove a user and update encrypted userPermissions.json.
|
||||||
*
|
|
||||||
* @param string $usernameToRemove The username to remove.
|
|
||||||
* @return array An array with either an error message or a success message.
|
|
||||||
*/
|
*/
|
||||||
public static function removeUser($usernameToRemove)
|
public static function removeUser($usernameToRemove)
|
||||||
{
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
global $encryptionKey;
|
||||||
|
|
||||||
|
if (!preg_match(REGEX_USER, $usernameToRemove)) {
|
||||||
|
return ["error" => "Invalid username"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return ["error" => "Users file not found"];
|
return ["error" => "Users file not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||||
$newUsers = [];
|
$newUsers = [];
|
||||||
$userFound = false;
|
$userFound = false;
|
||||||
|
|
||||||
// Loop through users; skip (remove) the specified user.
|
|
||||||
foreach ($existingUsers as $line) {
|
foreach ($existingUsers as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
if (count($parts) < 3) {
|
if (count($parts) < 3) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($parts[0] === $usernameToRemove) {
|
if (strcasecmp($parts[0], $usernameToRemove) === 0) {
|
||||||
$userFound = true;
|
$userFound = true;
|
||||||
continue; // Do not add this user to the new array.
|
continue; // skip this user
|
||||||
}
|
}
|
||||||
$newUsers[] = $line;
|
$newUsers[] = $line;
|
||||||
}
|
}
|
||||||
@@ -107,17 +113,56 @@ class userModel
|
|||||||
return ["error" => "User not found"];
|
return ["error" => "User not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the updated user list back to the file.
|
$newContent = $newUsers ? (implode(PHP_EOL, $newUsers) . PHP_EOL) : '';
|
||||||
file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL);
|
if (file_put_contents($usersFile, $newContent, LOCK_EX) === false) {
|
||||||
|
return ["error" => "Failed to update users file"];
|
||||||
|
}
|
||||||
|
|
||||||
// Update the userPermissions.json file.
|
// Update encrypted userPermissions.json — remove any key matching case-insensitively
|
||||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||||
if (file_exists($permissionsFile)) {
|
if (file_exists($permissionsFile)) {
|
||||||
$permissionsJson = file_get_contents($permissionsFile);
|
$raw = file_get_contents($permissionsFile);
|
||||||
$permissionsArray = json_decode($permissionsJson, true);
|
$decrypted = decryptData($raw, $encryptionKey);
|
||||||
if (is_array($permissionsArray) && isset($permissionsArray[$usernameToRemove])) {
|
$permissionsArray = $decrypted !== false
|
||||||
unset($permissionsArray[$usernameToRemove]);
|
? json_decode($decrypted, true)
|
||||||
file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT));
|
: (json_decode($raw, true) ?: []); // tolerate legacy plaintext
|
||||||
|
|
||||||
|
if (is_array($permissionsArray)) {
|
||||||
|
foreach (array_keys($permissionsArray) as $k) {
|
||||||
|
if (strcasecmp($k, $usernameToRemove) === 0) {
|
||||||
|
unset($permissionsArray[$k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$plain = json_encode($permissionsArray, JSON_PRETTY_PRINT);
|
||||||
|
$enc = encryptData($plain, $encryptionKey);
|
||||||
|
file_put_contents($permissionsFile, $enc, LOCK_EX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge from ACL (remove from every bucket in every folder)
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
|
if (method_exists('ACL', 'purgeUser')) {
|
||||||
|
ACL::purgeUser($usernameToRemove);
|
||||||
|
} else {
|
||||||
|
// Fallback inline purge if you haven't added ACL::purgeUser yet:
|
||||||
|
$aclPath = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||||
|
$acl = is_file($aclPath) ? json_decode((string)file_get_contents($aclPath), true) : [];
|
||||||
|
if (!is_array($acl)) $acl = ['folders' => [], 'groups' => []];
|
||||||
|
$buckets = ['owners','read','write','share','read_own'];
|
||||||
|
|
||||||
|
$changed = false;
|
||||||
|
foreach ($acl['folders'] ?? [] as $f => &$rec) {
|
||||||
|
foreach ($buckets as $b) {
|
||||||
|
if (!isset($rec[$b]) || !is_array($rec[$b])) { $rec[$b] = []; continue; }
|
||||||
|
$before = $rec[$b];
|
||||||
|
$rec[$b] = array_values(array_filter($rec[$b], fn($u) => strcasecmp((string)$u, $usernameToRemove) !== 0));
|
||||||
|
if ($rec[$b] !== $before) $changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($rec);
|
||||||
|
|
||||||
|
if ($changed) {
|
||||||
|
@file_put_contents($aclPath, json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,11 +170,7 @@ class userModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves permissions from the userPermissions.json file.
|
* Get permissions for current user (or all, if admin).
|
||||||
* If the current user is an admin, returns all permissions.
|
|
||||||
* Otherwise, returns only the permissions for the current user.
|
|
||||||
*
|
|
||||||
* @return array|object Returns an associative array of permissions or an empty object if none are found.
|
|
||||||
*/
|
*/
|
||||||
public static function getUserPermissions()
|
public static function getUserPermissions()
|
||||||
{
|
{
|
||||||
@@ -137,28 +178,24 @@ class userModel
|
|||||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||||
$permissionsArray = [];
|
$permissionsArray = [];
|
||||||
|
|
||||||
// Load permissions if the file exists.
|
|
||||||
if (file_exists($permissionsFile)) {
|
if (file_exists($permissionsFile)) {
|
||||||
$content = file_get_contents($permissionsFile);
|
$content = file_get_contents($permissionsFile);
|
||||||
// Attempt to decrypt the content.
|
$decrypted = decryptData($content, $encryptionKey);
|
||||||
$decryptedContent = decryptData($content, $encryptionKey);
|
if ($decrypted === false) {
|
||||||
if ($decryptedContent === false) {
|
// tolerate legacy plaintext
|
||||||
// If decryption fails, assume the content is plain JSON.
|
|
||||||
$permissionsArray = json_decode($content, true);
|
$permissionsArray = json_decode($content, true);
|
||||||
} else {
|
} else {
|
||||||
$permissionsArray = json_decode($decryptedContent, true);
|
$permissionsArray = json_decode($decrypted, true);
|
||||||
}
|
}
|
||||||
if (!is_array($permissionsArray)) {
|
if (!is_array($permissionsArray)) {
|
||||||
$permissionsArray = [];
|
$permissionsArray = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user is an admin, return all permissions.
|
if (!empty($_SESSION['isAdmin'])) {
|
||||||
if (isset($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) {
|
|
||||||
return $permissionsArray;
|
return $permissionsArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, return only the permissions for the currently logged-in user.
|
|
||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
foreach ($permissionsArray as $storedUsername => $data) {
|
foreach ($permissionsArray as $storedUsername => $data) {
|
||||||
if (strcasecmp($storedUsername, $username) === 0) {
|
if (strcasecmp($storedUsername, $username) === 0) {
|
||||||
@@ -166,15 +203,11 @@ class userModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no permissions are found, return an empty object.
|
|
||||||
return new stdClass();
|
return new stdClass();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates user permissions in the userPermissions.json file.
|
* Update permissions (encrypted on disk). Skips admins.
|
||||||
*
|
|
||||||
* @param array $permissions An array of permission updates.
|
|
||||||
* @return array An associative array with a success or error message.
|
|
||||||
*/
|
*/
|
||||||
public static function updateUserPermissions($permissions)
|
public static function updateUserPermissions($permissions)
|
||||||
{
|
{
|
||||||
@@ -182,113 +215,103 @@ class userModel
|
|||||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||||
$existingPermissions = [];
|
$existingPermissions = [];
|
||||||
|
|
||||||
// Load existing permissions if available and decrypt.
|
// Load existing (decrypt if needed)
|
||||||
if (file_exists($permissionsFile)) {
|
if (file_exists($permissionsFile)) {
|
||||||
$encryptedContent = file_get_contents($permissionsFile);
|
$encryptedContent = file_get_contents($permissionsFile);
|
||||||
$json = decryptData($encryptedContent, $encryptionKey);
|
$json = decryptData($encryptedContent, $encryptionKey);
|
||||||
$existingPermissions = json_decode($json, true);
|
if ($json === false) $json = $encryptedContent; // legacy plaintext
|
||||||
if (!is_array($existingPermissions)) {
|
$existingPermissions = json_decode($json, true) ?: [];
|
||||||
$existingPermissions = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load user roles from the users file.
|
// Load roles to skip admins
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
$userRoles = [];
|
$userRoles = [];
|
||||||
if (file_exists($usersFile)) {
|
if (file_exists($usersFile)) {
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
foreach ($lines as $line) {
|
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
if (count($parts) >= 3 && preg_match(REGEX_USER, $parts[0])) {
|
if (count($parts) >= 3 && preg_match(REGEX_USER, $parts[0])) {
|
||||||
// Use lowercase keys for consistency.
|
|
||||||
$userRoles[strtolower($parts[0])] = trim($parts[2]);
|
$userRoles[strtolower($parts[0])] = trim($parts[2]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each permission update.
|
$knownKeys = [
|
||||||
foreach ($permissions as $perm) {
|
'folderOnly','readOnly','disableUpload',
|
||||||
if (!isset($perm['username'])) {
|
'bypassOwnership','canShare','canZip','viewOwnOnly'
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$username = $perm['username'];
|
|
||||||
// Look up the user's role.
|
|
||||||
$role = isset($userRoles[strtolower($username)]) ? $userRoles[strtolower($username)] : null;
|
|
||||||
|
|
||||||
// Skip updating permissions for admin users.
|
|
||||||
if ($role === "1") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update permissions: default any missing value to false.
|
|
||||||
$existingPermissions[strtolower($username)] = [
|
|
||||||
'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false,
|
|
||||||
'readOnly' => isset($perm['readOnly']) ? (bool)$perm['readOnly'] : false,
|
|
||||||
'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Build a map of lowercase->actual key to update existing entries case-insensitively
|
||||||
|
$lcIndex = [];
|
||||||
|
foreach ($existingPermissions as $k => $_) {
|
||||||
|
$lcIndex[strtolower($k)] = $k;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the updated permissions array to JSON.
|
foreach ($permissions as $perm) {
|
||||||
$plainText = json_encode($existingPermissions, JSON_PRETTY_PRINT);
|
if (empty($perm['username'])) continue;
|
||||||
// Encrypt the JSON.
|
|
||||||
$encryptedData = encryptData($plainText, $encryptionKey);
|
$unameOrig = (string)$perm['username']; // preserve original case
|
||||||
// Save encrypted permissions back to the file.
|
$unameLc = strtolower($unameOrig);
|
||||||
$result = file_put_contents($permissionsFile, $encryptedData);
|
$role = $userRoles[$unameLc] ?? null;
|
||||||
if ($result === false) {
|
if ($role === "1") continue; // skip admins
|
||||||
|
|
||||||
|
// Find existing key case-insensitively; otherwise use original case as canonical
|
||||||
|
$storeKey = $lcIndex[$unameLc] ?? $unameOrig;
|
||||||
|
|
||||||
|
$current = $existingPermissions[$storeKey] ?? [];
|
||||||
|
foreach ($knownKeys as $k) {
|
||||||
|
if (array_key_exists($k, $perm)) {
|
||||||
|
$current[$k] = (bool)$perm[$k];
|
||||||
|
} elseif (!isset($current[$k])) {
|
||||||
|
$current[$k] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingPermissions[$storeKey] = $current;
|
||||||
|
$lcIndex[$unameLc] = $storeKey; // keep index up to date
|
||||||
|
}
|
||||||
|
|
||||||
|
$plain = json_encode($existingPermissions, JSON_PRETTY_PRINT);
|
||||||
|
$encrypted = encryptData($plain, $encryptionKey);
|
||||||
|
if (file_put_contents($permissionsFile, $encrypted) === false) {
|
||||||
return ["error" => "Failed to save user permissions."];
|
return ["error" => "Failed to save user permissions."];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ["success" => "User permissions updated successfully."];
|
return ["success" => "User permissions updated successfully."];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes the password for the given user.
|
* Change password (preserve TOTP + extra fields).
|
||||||
*
|
|
||||||
* @param string $username The username whose password is to be changed.
|
|
||||||
* @param string $oldPassword The old (current) password.
|
|
||||||
* @param string $newPassword The new password.
|
|
||||||
* @return array An array with either a success or error message.
|
|
||||||
*/
|
*/
|
||||||
public static function changePassword($username, $oldPassword, $newPassword)
|
public static function changePassword($username, $oldPassword, $newPassword)
|
||||||
{
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
if (!preg_match(REGEX_USER, $username)) {
|
||||||
|
return ["error" => "Invalid username"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return ["error" => "Users file not found"];
|
return ["error" => "Users file not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||||
$userFound = false;
|
$userFound = false;
|
||||||
$newLines = [];
|
$newLines = [];
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
// Expect at least 3 parts: username, hashed password, and role.
|
|
||||||
if (count($parts) < 3) {
|
if (count($parts) < 3) {
|
||||||
$newLines[] = $line;
|
$newLines[] = $line;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$storedUser = $parts[0];
|
$storedUser = $parts[0];
|
||||||
$storedHash = $parts[1];
|
$storedHash = $parts[1];
|
||||||
$storedRole = $parts[2];
|
|
||||||
// Preserve TOTP secret if it exists.
|
|
||||||
$totpSecret = (count($parts) >= 4) ? $parts[3] : "";
|
|
||||||
|
|
||||||
if ($storedUser === $username) {
|
if ($storedUser === $username) {
|
||||||
$userFound = true;
|
$userFound = true;
|
||||||
// Verify the old password.
|
|
||||||
if (!password_verify($oldPassword, $storedHash)) {
|
if (!password_verify($oldPassword, $storedHash)) {
|
||||||
return ["error" => "Old password is incorrect."];
|
return ["error" => "Old password is incorrect."];
|
||||||
}
|
}
|
||||||
// Hash the new password.
|
$parts[1] = password_hash($newPassword, PASSWORD_BCRYPT);
|
||||||
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
|
$newLines[] = implode(':', $parts);
|
||||||
|
|
||||||
// Rebuild the line, preserving TOTP secret if it exists.
|
|
||||||
if ($totpSecret !== "") {
|
|
||||||
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret;
|
|
||||||
} else {
|
|
||||||
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
$newLines[] = $line;
|
$newLines[] = $line;
|
||||||
}
|
}
|
||||||
@@ -298,148 +321,128 @@ class userModel
|
|||||||
return ["error" => "User not found."];
|
return ["error" => "User not found."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the updated users file.
|
$payload = implode(PHP_EOL, $newLines) . PHP_EOL;
|
||||||
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) {
|
if (file_put_contents($usersFile, $payload, LOCK_EX) === false) {
|
||||||
return ["success" => "Password updated successfully."];
|
|
||||||
} else {
|
|
||||||
return ["error" => "Could not update password."];
|
return ["error" => "Could not update password."];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ["success" => "Password updated successfully."];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the user panel settings by disabling the TOTP secret if TOTP is not enabled.
|
* Update panel: if TOTP disabled, clear secret.
|
||||||
*
|
|
||||||
* @param string $username The username whose panel settings are being updated.
|
|
||||||
* @param bool $totp_enabled Whether TOTP is enabled.
|
|
||||||
* @return array An array indicating success or failure.
|
|
||||||
*/
|
*/
|
||||||
public static function updateUserPanel($username, $totp_enabled)
|
public static function updateUserPanel($username, $totp_enabled)
|
||||||
{
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
if (!preg_match(REGEX_USER, $username)) {
|
||||||
|
return ["error" => "Invalid username"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return ["error" => "Users file not found"];
|
return ["error" => "Users file not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If TOTP is disabled, update the file to clear the TOTP secret.
|
|
||||||
if (!$totp_enabled) {
|
if (!$totp_enabled) {
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||||
$newLines = [];
|
$newLines = [];
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
// Leave lines with fewer than three parts unchanged.
|
|
||||||
if (count($parts) < 3) {
|
if (count($parts) < 3) {
|
||||||
$newLines[] = $line;
|
$newLines[] = $line;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($parts[0] === $username) {
|
if ($parts[0] === $username) {
|
||||||
// If a fourth field (TOTP secret) exists, clear it; otherwise, append an empty field.
|
while (count($parts) < 4) {
|
||||||
if (count($parts) >= 4) {
|
|
||||||
$parts[3] = "";
|
|
||||||
} else {
|
|
||||||
$parts[] = "";
|
$parts[] = "";
|
||||||
}
|
}
|
||||||
|
$parts[3] = "";
|
||||||
$newLines[] = implode(':', $parts);
|
$newLines[] = implode(':', $parts);
|
||||||
} else {
|
} else {
|
||||||
$newLines[] = $line;
|
$newLines[] = $line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX) === false) {
|
||||||
if ($result === false) {
|
|
||||||
return ["error" => "Failed to disable TOTP secret"];
|
return ["error" => "Failed to disable TOTP secret"];
|
||||||
}
|
}
|
||||||
return ["success" => "User panel updated: TOTP disabled"];
|
return ["success" => "User panel updated: TOTP disabled"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If TOTP is enabled, do nothing.
|
|
||||||
return ["success" => "User panel updated: TOTP remains enabled"];
|
return ["success" => "User panel updated: TOTP remains enabled"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disables the TOTP secret for the specified user.
|
* Clear TOTP secret.
|
||||||
*
|
|
||||||
* @param string $username The user for whom TOTP should be disabled.
|
|
||||||
* @return bool True if the secret was cleared; false otherwise.
|
|
||||||
*/
|
*/
|
||||||
public static function disableTOTPSecret($username)
|
public static function disableTOTPSecret($username)
|
||||||
{
|
{
|
||||||
global $encryptionKey; // In case it's used in this model context.
|
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||||
$modified = false;
|
$modified = false;
|
||||||
$newLines = [];
|
$newLines = [];
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
// If the line doesn't have at least three parts, leave it unchanged.
|
|
||||||
if (count($parts) < 3) {
|
if (count($parts) < 3) {
|
||||||
$newLines[] = $line;
|
$newLines[] = $line;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($parts[0] === $username) {
|
if ($parts[0] === $username) {
|
||||||
// If a fourth field exists, clear it; otherwise, append an empty field.
|
while (count($parts) < 4) {
|
||||||
if (count($parts) >= 4) {
|
|
||||||
$parts[3] = "";
|
|
||||||
} else {
|
|
||||||
$parts[] = "";
|
$parts[] = "";
|
||||||
}
|
}
|
||||||
|
$parts[3] = "";
|
||||||
$modified = true;
|
$modified = true;
|
||||||
$newLines[] = implode(":", $parts);
|
$newLines[] = implode(":", $parts);
|
||||||
} else {
|
} else {
|
||||||
$newLines[] = $line;
|
$newLines[] = $line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($modified) {
|
if ($modified) {
|
||||||
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
return file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX) !== false;
|
||||||
}
|
}
|
||||||
return $modified;
|
return $modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to recover TOTP for a user using the supplied recovery code.
|
* Recover via recovery code.
|
||||||
*
|
|
||||||
* @param string $userId The user identifier.
|
|
||||||
* @param string $recoveryCode The recovery code provided by the user.
|
|
||||||
* @return array An associative array with keys 'status' and 'message'.
|
|
||||||
*/
|
*/
|
||||||
public static function recoverTOTP($userId, $recoveryCode)
|
public static function recoverTOTP($userId, $recoveryCode)
|
||||||
{
|
{
|
||||||
// --- Rate‑limit recovery attempts ---
|
// Rate limit storage
|
||||||
$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json';
|
$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json';
|
||||||
$attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : [];
|
$attempts = is_file($attemptsFile) ? (json_decode(@file_get_contents($attemptsFile), true) ?: []) : [];
|
||||||
$key = $_SERVER['REMOTE_ADDR'] . '|' . $userId;
|
$key = ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0') . '|' . $userId;
|
||||||
$now = time();
|
$now = time();
|
||||||
|
|
||||||
if (isset($attempts[$key])) {
|
if (isset($attempts[$key])) {
|
||||||
// Prune attempts older than 15 minutes.
|
$attempts[$key] = array_values(array_filter($attempts[$key], fn($ts) => $ts > $now - 900));
|
||||||
$attempts[$key] = array_filter($attempts[$key], function ($ts) use ($now) {
|
|
||||||
return $ts > $now - 900;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (count($attempts[$key] ?? []) >= 5) {
|
if (count($attempts[$key] ?? []) >= 5) {
|
||||||
return ['status' => 'error', 'message' => 'Too many attempts. Try again later.'];
|
return ['status' => 'error', 'message' => 'Too many attempts. Try again later.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Load user metadata file ---
|
// User JSON file
|
||||||
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
||||||
if (!file_exists($userFile)) {
|
if (!file_exists($userFile)) {
|
||||||
return ['status' => 'error', 'message' => 'User not found'];
|
return ['status' => 'error', 'message' => 'User not found'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Open and lock file ---
|
|
||||||
$fp = fopen($userFile, 'c+');
|
$fp = fopen($userFile, 'c+');
|
||||||
if (!$fp || !flock($fp, LOCK_EX)) {
|
if (!$fp || !flock($fp, LOCK_EX)) {
|
||||||
|
if ($fp) fclose($fp);
|
||||||
return ['status' => 'error', 'message' => 'Server error'];
|
return ['status' => 'error', 'message' => 'Server error'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$fileContents = stream_get_contents($fp);
|
$fileContents = stream_get_contents($fp);
|
||||||
$data = json_decode($fileContents, true) ?: [];
|
$data = json_decode($fileContents, true) ?: [];
|
||||||
|
|
||||||
// --- Check recovery code ---
|
|
||||||
if (empty($recoveryCode)) {
|
if (empty($recoveryCode)) {
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
@@ -448,19 +451,19 @@ class userModel
|
|||||||
|
|
||||||
$storedHash = $data['totp_recovery_code'] ?? null;
|
$storedHash = $data['totp_recovery_code'] ?? null;
|
||||||
if (!$storedHash || !password_verify($recoveryCode, $storedHash)) {
|
if (!$storedHash || !password_verify($recoveryCode, $storedHash)) {
|
||||||
// Record failed attempt.
|
// record failed attempt
|
||||||
$attempts[$key][] = $now;
|
$attempts[$key][] = $now;
|
||||||
file_put_contents($attemptsFile, json_encode($attempts), LOCK_EX);
|
@file_put_contents($attemptsFile, json_encode($attempts, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
return ['status' => 'error', 'message' => 'Invalid recovery code'];
|
return ['status' => 'error', 'message' => 'Invalid recovery code'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Invalidate recovery code ---
|
// Invalidate code
|
||||||
$data['totp_recovery_code'] = null;
|
$data['totp_recovery_code'] = null;
|
||||||
rewind($fp);
|
rewind($fp);
|
||||||
ftruncate($fp, 0);
|
ftruncate($fp, 0);
|
||||||
fwrite($fp, json_encode($data));
|
fwrite($fp, json_encode($data, JSON_PRETTY_PRINT));
|
||||||
fflush($fp);
|
fflush($fp);
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
@@ -469,10 +472,7 @@ class userModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a random recovery code.
|
* Generate random recovery code.
|
||||||
*
|
|
||||||
* @param int $length Length of the recovery code.
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
private static function generateRecoveryCode($length = 12)
|
private static function generateRecoveryCode($length = 12)
|
||||||
{
|
{
|
||||||
@@ -486,45 +486,34 @@ class userModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves a new TOTP recovery code for the specified user.
|
* Save new TOTP recovery code (hash on disk) and return plaintext to caller.
|
||||||
*
|
|
||||||
* @param string $userId The username of the user.
|
|
||||||
* @return array An associative array with the status and recovery code (if successful).
|
|
||||||
*/
|
*/
|
||||||
public static function saveTOTPRecoveryCode($userId)
|
public static function saveTOTPRecoveryCode($userId)
|
||||||
{
|
{
|
||||||
// Determine the user file path.
|
|
||||||
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
||||||
|
|
||||||
// Ensure the file exists; if not, create it with default data.
|
|
||||||
if (!file_exists($userFile)) {
|
if (!file_exists($userFile)) {
|
||||||
$defaultData = [];
|
if (file_put_contents($userFile, json_encode([], JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||||
if (file_put_contents($userFile, json_encode($defaultData)) === false) {
|
|
||||||
return ['status' => 'error', 'message' => 'Server error: could not create user file'];
|
return ['status' => 'error', 'message' => 'Server error: could not create user file'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a new recovery code.
|
|
||||||
$recoveryCode = self::generateRecoveryCode();
|
$recoveryCode = self::generateRecoveryCode();
|
||||||
$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT);
|
$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT);
|
||||||
|
|
||||||
// Open the file, lock it, and update the totp_recovery_code field.
|
|
||||||
$fp = fopen($userFile, 'c+');
|
$fp = fopen($userFile, 'c+');
|
||||||
if (!$fp || !flock($fp, LOCK_EX)) {
|
if (!$fp || !flock($fp, LOCK_EX)) {
|
||||||
|
if ($fp) fclose($fp);
|
||||||
return ['status' => 'error', 'message' => 'Server error: could not lock user file'];
|
return ['status' => 'error', 'message' => 'Server error: could not lock user file'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and decode the existing JSON.
|
|
||||||
$contents = stream_get_contents($fp);
|
$contents = stream_get_contents($fp);
|
||||||
$data = json_decode($contents, true) ?: [];
|
$data = json_decode($contents, true) ?: [];
|
||||||
|
|
||||||
// Update the totp_recovery_code field.
|
|
||||||
$data['totp_recovery_code'] = $recoveryHash;
|
$data['totp_recovery_code'] = $recoveryHash;
|
||||||
|
|
||||||
// Write the new data.
|
|
||||||
rewind($fp);
|
rewind($fp);
|
||||||
ftruncate($fp, 0);
|
ftruncate($fp, 0);
|
||||||
fwrite($fp, json_encode($data)); // Plain JSON in production.
|
fwrite($fp, json_encode($data, JSON_PRETTY_PRINT));
|
||||||
fflush($fp);
|
fflush($fp);
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
@@ -533,11 +522,7 @@ class userModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up TOTP for the specified user by retrieving or generating a TOTP secret,
|
* Setup TOTP & build QR PNG.
|
||||||
* then builds and returns a QR code image for the OTPAuth URL.
|
|
||||||
*
|
|
||||||
* @param string $username The username for which to set up TOTP.
|
|
||||||
* @return array An associative array with keys 'imageData' and 'mimeType', or 'error'.
|
|
||||||
*/
|
*/
|
||||||
public static function setupTOTP($username)
|
public static function setupTOTP($username)
|
||||||
{
|
{
|
||||||
@@ -548,9 +533,9 @@ class userModel
|
|||||||
return ['error' => 'Users file not found'];
|
return ['error' => 'Users file not found'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for an existing TOTP secret.
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
||||||
$totpSecret = null;
|
$totpSecret = null;
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
|
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
|
||||||
@@ -559,19 +544,18 @@ class userModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the TwoFactorAuth library to create a new secret if none found.
|
|
||||||
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
||||||
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), // QR code provider
|
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
|
||||||
'FileRise', // issuer
|
'FileRise',
|
||||||
6, // number of digits
|
6,
|
||||||
30, // period (seconds)
|
30,
|
||||||
\RobThree\Auth\Algorithm::Sha1 // algorithm
|
\RobThree\Auth\Algorithm::Sha1
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$totpSecret) {
|
if (!$totpSecret) {
|
||||||
$totpSecret = $tfa->createSecret();
|
$totpSecret = $tfa->createSecret();
|
||||||
$encryptedSecret = encryptData($totpSecret, $encryptionKey);
|
$encryptedSecret = encryptData($totpSecret, $encryptionKey);
|
||||||
|
|
||||||
// Update the user’s line with the new encrypted secret.
|
|
||||||
$newLines = [];
|
$newLines = [];
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
@@ -589,8 +573,7 @@ class userModel
|
|||||||
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the OTPAuth URL.
|
// Prefer admin-configured otpauth template if present
|
||||||
// Try to load a global OTPAuth URL template from admin configuration.
|
|
||||||
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
||||||
$globalOtpauthUrl = "";
|
$globalOtpauthUrl = "";
|
||||||
if (file_exists($adminConfigFile)) {
|
if (file_exists($adminConfigFile)) {
|
||||||
@@ -598,7 +581,7 @@ class userModel
|
|||||||
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
||||||
if ($decryptedContent !== false) {
|
if ($decryptedContent !== false) {
|
||||||
$config = json_decode($decryptedContent, true);
|
$config = json_decode($decryptedContent, true);
|
||||||
if (isset($config['globalOtpauthUrl']) && !empty($config['globalOtpauthUrl'])) {
|
if (!empty($config['globalOtpauthUrl'])) {
|
||||||
$globalOtpauthUrl = $config['globalOtpauthUrl'];
|
$globalOtpauthUrl = $config['globalOtpauthUrl'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -606,14 +589,17 @@ class userModel
|
|||||||
|
|
||||||
if (!empty($globalOtpauthUrl)) {
|
if (!empty($globalOtpauthUrl)) {
|
||||||
$label = "FileRise:" . $username;
|
$label = "FileRise:" . $username;
|
||||||
$otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl);
|
$otpauthUrl = str_replace(
|
||||||
|
["{label}", "{secret}"],
|
||||||
|
[urlencode($label), $totpSecret],
|
||||||
|
$globalOtpauthUrl
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
$label = urlencode("FileRise:" . $username);
|
$label = urlencode("FileRise:" . $username);
|
||||||
$issuer = urlencode("FileRise");
|
$issuer = urlencode("FileRise");
|
||||||
$otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}";
|
$otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the QR code image using the Endroid QR Code Builder.
|
|
||||||
$result = \Endroid\QrCode\Builder\Builder::create()
|
$result = \Endroid\QrCode\Builder\Builder::create()
|
||||||
->writer(new \Endroid\QrCode\Writer\PngWriter())
|
->writer(new \Endroid\QrCode\Writer\PngWriter())
|
||||||
->data($otpauthUrl)
|
->data($otpauthUrl)
|
||||||
@@ -626,10 +612,7 @@ class userModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the decrypted TOTP secret for a given user.
|
* Get decrypted TOTP secret.
|
||||||
*
|
|
||||||
* @param string $username
|
|
||||||
* @return string|null Returns the TOTP secret if found, or null if not.
|
|
||||||
*/
|
*/
|
||||||
public static function getTOTPSecret($username)
|
public static function getTOTPSecret($username)
|
||||||
{
|
{
|
||||||
@@ -638,10 +621,9 @@ class userModel
|
|||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
// Expect at least 4 parts: username, hash, role, and TOTP secret.
|
|
||||||
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
|
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
|
||||||
return decryptData($parts[3], $encryptionKey);
|
return decryptData($parts[3], $encryptionKey);
|
||||||
}
|
}
|
||||||
@@ -650,10 +632,7 @@ class userModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to get a user's role from users.txt.
|
* Get role ('1' admin, '0' user) or null.
|
||||||
*
|
|
||||||
* @param string $username
|
|
||||||
* @return string|null
|
|
||||||
*/
|
*/
|
||||||
public static function getUserRole($username)
|
public static function getUserRole($username)
|
||||||
{
|
{
|
||||||
@@ -670,6 +649,9 @@ class userModel
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single user’s info (admin flag, TOTP status, profile picture).
|
||||||
|
*/
|
||||||
public static function getUser(string $username): array
|
public static function getUser(string $username): array
|
||||||
{
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
@@ -678,19 +660,19 @@ class userModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
// split *all* the fields
|
|
||||||
$parts = explode(':', $line);
|
$parts = explode(':', $line);
|
||||||
|
|
||||||
if ($parts[0] !== $username) {
|
if ($parts[0] !== $username) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// determine admin & totp
|
|
||||||
$isAdmin = (isset($parts[2]) && $parts[2] === '1');
|
$isAdmin = (isset($parts[2]) && $parts[2] === '1');
|
||||||
$totpEnabled = !empty($parts[3]);
|
$totpEnabled = !empty($parts[3]);
|
||||||
// profile_picture is the 5th field if present
|
|
||||||
$pic = isset($parts[4]) ? $parts[4] : '';
|
$pic = isset($parts[4]) ? $parts[4] : '';
|
||||||
|
|
||||||
|
// Normalize to a leading slash (UI expects /uploads/…)
|
||||||
|
if ($pic !== '' && $pic[0] !== '/') {
|
||||||
|
$pic = '/' . $pic;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'username' => $parts[0],
|
'username' => $parts[0],
|
||||||
'isAdmin' => $isAdmin,
|
'isAdmin' => $isAdmin,
|
||||||
@@ -699,19 +681,13 @@ class userModel
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return []; // user not found
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistently set the profile picture URL for a given user,
|
* Persist profile picture URL as 5th field (keeps TOTP secret intact).
|
||||||
* storing it in the 5th field so we leave the 4th (TOTP secret) untouched.
|
|
||||||
*
|
*
|
||||||
* users.txt format:
|
* users.txt: username:hash:isAdmin:totp_secret:profile_picture
|
||||||
* username:hash:isAdmin:totp_secret:profile_picture
|
|
||||||
*
|
|
||||||
* @param string $username
|
|
||||||
* @param string $url The public URL (e.g. "/uploads/profile_pics/…")
|
|
||||||
* @return array ['success'=>true] or ['success'=>false,'error'=>'…']
|
|
||||||
*/
|
*/
|
||||||
public static function setProfilePicture(string $username, string $url): array
|
public static function setProfilePicture(string $username, string $url): array
|
||||||
{
|
{
|
||||||
@@ -720,21 +696,22 @@ class userModel
|
|||||||
return ['success' => false, 'error' => 'Users file not found'];
|
return ['success' => false, 'error' => 'Users file not found'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES);
|
// Ensure leading slash (consistent with controller response)
|
||||||
|
$url = '/' . ltrim($url, '/');
|
||||||
|
|
||||||
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES) ?: [];
|
||||||
$out = [];
|
$out = [];
|
||||||
$found = false;
|
$found = false;
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
|
if ($line === '') { $out[] = $line; continue; }
|
||||||
$parts = explode(':', $line);
|
$parts = explode(':', $line);
|
||||||
if ($parts[0] === $username) {
|
if ($parts[0] === $username) {
|
||||||
$found = true;
|
$found = true;
|
||||||
// Ensure we have at least 5 fields
|
|
||||||
while (count($parts) < 5) {
|
while (count($parts) < 5) {
|
||||||
$parts[] = '';
|
$parts[] = '';
|
||||||
}
|
}
|
||||||
// Write profile_picture into the 5th field (index 4)
|
$parts[4] = $url;
|
||||||
$parts[4] = ltrim($url, '/'); // or $url if leading slash is desired
|
|
||||||
// Re-assemble (this preserves parts[3] completely)
|
|
||||||
$line = implode(':', $parts);
|
$line = implode(':', $parts);
|
||||||
}
|
}
|
||||||
$out[] = $line;
|
$out[] = $line;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
$parentKey = $this->folderKeyForPath($this->path);
|
||||||
|
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;
|
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||||
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1);
|
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 . (
|
||||||
|
$folderKey === 'root'
|
||||||
? 'root_metadata.json'
|
? 'root_metadata.json'
|
||||||
: str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json');
|
: str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json'
|
||||||
|
);
|
||||||
$metadata = [];
|
|
||||||
if (file_exists($metaFile)) {
|
|
||||||
$decoded = json_decode(file_get_contents($metaFile), true);
|
|
||||||
if (is_array($decoded)) {
|
|
||||||
$metadata = $decoded;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function loadMeta(string $folderKey): array {
|
||||||
|
$mf = $this->metaFile($folderKey);
|
||||||
|
if (!is_file($mf)) return [];
|
||||||
|
$d = json_decode(@file_get_contents($mf), true);
|
||||||
|
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
15
start.sh
15
start.sh
@@ -56,6 +56,9 @@ fi
|
|||||||
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
|
||||||
@@ -153,5 +156,17 @@ if [ "${SCAN_ON_START:-}" = "true" ]; then
|
|||||||
fi
|
fi
|
||||||
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