Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
371a763fb4 | ||
|
|
ee717af750 | ||
|
|
0ad7034a7d | ||
|
|
d29900d6ba | ||
|
|
5ffc068041 | ||
|
|
1935cb2442 | ||
|
|
af9887e651 | ||
|
|
327eea2835 | ||
|
|
3843daa228 | ||
|
|
169e03be5d | ||
|
|
be605b4522 | ||
|
|
090286164d | ||
|
|
dc1649ace3 | ||
|
|
b6d86b7896 |
210
CHANGELOG.md
210
CHANGELOG.md
@@ -1,5 +1,215 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 10/22/2025 (v1.6.0)
|
||||||
|
|
||||||
|
feat(acl): granular per-folder permissions + stricter gates; WebDAV & UI aligned
|
||||||
|
|
||||||
|
- Add granular ACL buckets: create, upload, edit, rename, copy, move, delete, extract, share_file, share_folder
|
||||||
|
- Implement ACL::canX helpers and expand upsert/explicit APIs (preserve read_own)
|
||||||
|
- Enforce “write no longer implies read” in canRead; use granular gates for write-ish ops
|
||||||
|
- WebDAV: use canDelete for DELETE, canUpload/canEdit + disableUpload for PUT; enforce ownership on overwrite
|
||||||
|
- Folder create: require Manage/Owner on parent; normalize paths; seed ACL; rollback on failure
|
||||||
|
- FileController: refactor copy/move/rename/delete/extract to granular gates + folder-scope checks + own-only ownership enforcement
|
||||||
|
- Capabilities API: compute effective actions with scope + readOnly/disableUpload; protect root
|
||||||
|
- Admin Panel (v1.6.0): new Folder Access editor with granular caps, inheritance hints, bulk toggles, and UX validations
|
||||||
|
- getFileList: keep root visible but inert for users without visibility; apply own-only filtering server-side
|
||||||
|
- Bump version to v1.6.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/20/2025 (v1.5.3)
|
||||||
|
|
||||||
|
security(acl): enforce folder-scope & own-only; fix file list “Select All”; harden ops
|
||||||
|
|
||||||
|
### fileListView.js (v1.5.3)
|
||||||
|
|
||||||
|
- Restore master “Select All” checkbox behavior and row highlighting.
|
||||||
|
- Keep selection working with own-only filtered lists.
|
||||||
|
- Build preview/thumb URLs via secure API endpoints; avoid direct /uploads.
|
||||||
|
- Minor UI polish: slider wiring and pagination focus handling.
|
||||||
|
|
||||||
|
### FileController.php (v1.5.3)
|
||||||
|
|
||||||
|
- Add enforceFolderScope($folder, $user, $perms, $need) and apply across actions.
|
||||||
|
- Copy/Move: require read on source, write on destination; apply scope on both.
|
||||||
|
- When user only has read_own, enforce per-file ownership (uploader==user).
|
||||||
|
- Extract ZIP: require write + scope; consistent 403 messages.
|
||||||
|
- Save/Rename/Delete/Create: tighten ACL checks; block dangerous extensions; consistent CSRF/Auth handling and error codes.
|
||||||
|
- Download/ZIP: honor read vs read_own; own-only gates by uploader; safer headers.
|
||||||
|
|
||||||
|
### FolderController.php (v1.5.3)
|
||||||
|
|
||||||
|
- Align with ACL: enforce folder-scope for non-admins; require owner or bypass for destructive ops.
|
||||||
|
- Create/Rename/Delete: gate by write on parent/target + ownership when needed.
|
||||||
|
- Share folder link: require share capability; forbid root sharing for non-admins; validate expiry; optional password.
|
||||||
|
- Folder listing: return only folders user can fully view or has read_own.
|
||||||
|
- Shared downloads/uploads: stricter validation, headers, and error handling.
|
||||||
|
|
||||||
|
This commits a consistent, least-privilege ACL model (owners/read/write/share/read_own), fixes bulk-select in the UI, and closes scope/ownership gaps across file & folder actions.
|
||||||
|
|
||||||
|
feat(dnd): default cards to sidebar on medium screens when no saved layout
|
||||||
|
|
||||||
|
- Adds one-time responsive default in loadSidebarOrder() (uses layoutDefaultApplied_v1)
|
||||||
|
- Preserves existing sidebarOrder/headerOrder and small-screen behavior
|
||||||
|
- Keeps user changes persistent; no override once a layout exists
|
||||||
|
|
||||||
|
feat(editor): make modal non-blocking; add SRI + timeout for CodeMirror mode loads
|
||||||
|
|
||||||
|
- Build the editor modal immediately and wire close (✖, Close button, and Esc) before any async work, so the UI is always dismissible.
|
||||||
|
- Restore MODE_URL and add normalizeModeName() to resolve aliases (text/html → htmlmixed, php → application/x-httpd-php).
|
||||||
|
- Add SRI for each lazily loaded mode (MODE_SRI) and apply integrity/crossOrigin on script tags; switch to async and improved error messages.
|
||||||
|
- Introduce MODE_LOAD_TIMEOUT_MS=2500 and Promise.race() to init in text/plain if a mode is slow; auto-upgrade to the real mode once it arrives.
|
||||||
|
- Graceful fallback: if CodeMirror core isn’t present, keep textarea, enable Save, and proceed.
|
||||||
|
- Minor UX: disable Save until the editor is ready, support theme toggling, better resize handling, and font size controls without blocking.
|
||||||
|
|
||||||
|
Security: Locks CDN mode scripts with SRI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/19/2025 (v1.5.2)
|
||||||
|
|
||||||
|
fix(admin): modal bugs; chore(api): update ReDoc SRI; docs(openapi): add annotations + spec
|
||||||
|
|
||||||
|
- adminPanel.js
|
||||||
|
- Fix modal open/close reliability and stacking order
|
||||||
|
- Prevent background scroll while modal is open
|
||||||
|
- Tidy focus/keyboard handling for better UX
|
||||||
|
|
||||||
|
- style.css
|
||||||
|
- Polish styles for Folder Access + Users views (spacing, tables, badges)
|
||||||
|
- Improve responsiveness and visual consistency
|
||||||
|
|
||||||
|
- api.php
|
||||||
|
- Update Redoc SRI hash and pin to the current bundle URL
|
||||||
|
|
||||||
|
- OpenAPI
|
||||||
|
- Add/refresh inline @OA annotations across endpoints
|
||||||
|
- Introduce src/openapi/Components.php with base Info/Server,
|
||||||
|
common responses, and shared components
|
||||||
|
- Regenerate and commit openapi.json.dist
|
||||||
|
|
||||||
|
- public/js/adminPanel.js
|
||||||
|
- public/css/style.css
|
||||||
|
- public/api.php
|
||||||
|
- src/openapi/Components.php
|
||||||
|
- openapi.json.dist
|
||||||
|
- public/api/** (annotated endpoints)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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)
|
## 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
|
feat(permissions)!: granular ACL (bypassOwnership/canShare/canZip/viewOwnOnly), admin panel v1.4.0 UI, and broad hardening across controllers/models/frontend
|
||||||
|
|||||||
79
README.md
79
README.md
@@ -2,15 +2,24 @@
|
|||||||
|
|
||||||
[](https://github.com/error311/FileRise)
|
[](https://github.com/error311/FileRise)
|
||||||
[](https://hub.docker.com/r/error311/filerise-docker)
|
[](https://hub.docker.com/r/error311/filerise-docker)
|
||||||
|
[](https://github.com/error311/filerise-docker/actions/workflows/main.yml)
|
||||||
[](https://github.com/error311/FileRise/actions/workflows/ci.yml)
|
[](https://github.com/error311/FileRise/actions/workflows/ci.yml)
|
||||||
[](https://demo.filerise.net) **demo / demo**
|
[](https://demo.filerise.net)
|
||||||
[](https://github.com/error311/FileRise/releases)
|
[](https://github.com/error311/FileRise/releases)
|
||||||
[](LICENSE)
|
[](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)
|
**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, responsive web interface.
|
||||||
|
**FileRise** is lightweight yet powerful — your personal cloud drive that you fully control.
|
||||||
|
|
||||||
|
Now featuring **Granular Access Control (ACL)** with per-folder permissions, inheritance, and live admin editing.
|
||||||
|
Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage* on a per-user, per-folder basis — enforced across the UI, API, and WebDAV.
|
||||||
|
|
||||||
|
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
|
||||||
|
|
||||||
|
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||||
|
|
||||||
**4/3/2025 Video demo:**
|
**4/3/2025 Video demo:**
|
||||||
|
|
||||||
@@ -23,29 +32,57 @@ Upload, organize, and share files or folders through a sleek web interface. **Fi
|
|||||||
|
|
||||||
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
|
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||||
|
|
||||||
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with pause/resumable chunked uploads and shows real-time progress for each file. FileRise will pick up where it left off if your connection drops.
|
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with resumable chunked uploads, pause/resume, and real-time progress. If your connection drops, FileRise resumes automatically.
|
||||||
|
|
||||||
- 🗂️ **File Management:** Full set of file/folder operations – move or copy files (via intuitive drag-drop or dialogs), rename items, and delete in batches. You can download selected files as a ZIP archive or extract uploaded ZIP files server-side. Organize content with an interactive folder tree and breadcrumb navigation for quick jumps.
|
- 🗂️ **File Management:** Full suite of operations — move/copy (via drag-drop or dialogs), rename, and batch delete. Download selected files as ZIPs or extract uploaded ZIPs server-side. Organize with an interactive folder tree and breadcrumbs for instant navigation.
|
||||||
|
|
||||||
- 🗃️ **Folder Sharing & File Sharing:** Share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items 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).
|
- 🗃️ **Folder & File Sharing:** Share folders or individual files with expiring, optionally password-protected links. Shared folders can accept external uploads (if enabled). Listings are paginated (10 items/page) with file sizes shown in MB.
|
||||||
|
|
||||||
- 🔌 **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.
|
- 🔐 **Granular Access Control (ACL):**
|
||||||
|
Per-folder permissions for **owners**, **view**, **view (own)**, **write**, **manage**, **share**, and extended granular capabilities.
|
||||||
|
Each grant controls specific actions across the UI, API, and WebDAV:
|
||||||
|
|
||||||
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
|
| Permission | Description |
|
||||||
|
|-------------|-------------|
|
||||||
|
| **Manage (Owner)** | Full control of folder and subfolders. Can edit ACLs, rename/delete/create folders, and share items. Implies all other permissions for that folder and below. |
|
||||||
|
| **View (All)** | Allows viewing all files within the folder. Required for folder-level sharing. |
|
||||||
|
| **View (Own)** | Restricts visibility to files uploaded by the user only. Ideal for drop zones or limited-access users. |
|
||||||
|
| **Write** | Grants general write access — enables renaming, editing, moving, copying, deleting, and extracting files. |
|
||||||
|
| **Create** | Allows creating subfolders. Automatically granted to *Manage* users. |
|
||||||
|
| **Upload** | Allows uploading new files without granting full write privileges. |
|
||||||
|
| **Edit / Rename / Copy / Move / Delete / Extract** | Individually toggleable granular file operations. |
|
||||||
|
| **Share File / Share Folder** | Controls sharing capabilities. Folder shares require full View (All). |
|
||||||
|
|
||||||
- 📝 **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.
|
- **Automatic Propagation:** Enabling **Manage** on a folder applies to all subfolders; deselecting subfolder permissions overrides inheritance in the UI.
|
||||||
|
|
||||||
- 🏷️ **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.
|
ACL enforcement is centralized and atomic across:
|
||||||
|
- **Admin Panel:** Interactive ACL editor with batch save and dynamic inheritance visualization.
|
||||||
|
- **API Endpoints:** All file/folder operations validate server-side.
|
||||||
|
- **WebDAV:** Uses the same ACL engine — View / Own determine listings, granular permissions control upload/edit/delete/create.
|
||||||
|
|
||||||
- 🔒 **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.
|
- 🔌 **WebDAV (ACL-Aware):** Mount FileRise as a drive (Cyberduck, WinSCP, Finder, etc.) or access via `curl`.
|
||||||
|
- Listings require **View** or **View (Own)**.
|
||||||
|
- Uploads require **Upload**.
|
||||||
|
- Overwrites require **Edit**.
|
||||||
|
- Deletes require **Delete**.
|
||||||
|
- Creating folders requires **Create** or **Manage**.
|
||||||
|
- All ACLs and ownership rules are enforced exactly as in the web UI.
|
||||||
|
|
||||||
- 🎨 **Responsive UI (Dark/Light Mode):** Mobile-friendly layout with theme toggle. The interface remembers your preferences (layout, items per page, last visited folder, etc.).
|
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) with interactive HTML docs (`api.html`) via Redoc.
|
||||||
|
|
||||||
- 🌐 **Internationalization & Localization:** Switch languages via the UI (English, Spanish, French, German). Contributions welcome.
|
- 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers.
|
||||||
|
|
||||||
- 🗑️ **Trash & File Recovery:** Deleted items go to Trash first; admins can restore or empty. Old trash entries auto-purge (default 3 days).
|
- 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents.
|
||||||
|
|
||||||
- ⚙️ **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.
|
- 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak).
|
||||||
|
|
||||||
|
- 🗑️ **Trash & Recovery:** Deleted items move to Trash for recovery (default 3-day retention). Admins can restore or purge globally.
|
||||||
|
|
||||||
|
- 🎨 **Responsive UI (Dark/Light Mode):** Modern, mobile-friendly design with persistent preferences (theme, layout, last folder, etc.).
|
||||||
|
|
||||||
|
- 🌐 **Internationalization:** English, Spanish, French, and German available. Community translations welcome.
|
||||||
|
|
||||||
|
- ⚙️ **Lightweight & Self-Contained:** Runs on PHP 8.3+, no external DB required. Single-folder or Docker deployment with minimal footprint, optimized for Unraid and self-hosting.
|
||||||
|
|
||||||
(For full features and changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [CHANGELOG](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or [Releases](https://github.com/error311/FileRise/releases).)
|
(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).)
|
||||||
|
|
||||||
@@ -56,7 +93,7 @@ Upload, organize, and share files or folders through a sleek web interface. **Fi
|
|||||||
[](https://demo.filerise.net)
|
[](https://demo.filerise.net)
|
||||||
**Demo credentials:** `demo` / `demo`
|
**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!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -281,6 +318,16 @@ For more Q&A or to ask for help, open a Discussion or Issue.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Security posture
|
||||||
|
|
||||||
|
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening).
|
||||||
|
Advisories: [GHSA-6p87-q9rh-95wh](https://github.com/error311/FileRise/security/advisories/GHSA-6p87-q9rh-95wh) (≤ 1.3.15), [GHSA-jm96-2w52-5qjj](https://github.com/error311/FileRise/security/advisories/GHSA-jm96-2w52-5qjj) (v1.4.0). Fixed in **v1.5.0**. Thanks to [@kiwi865](https://github.com/kiwi865) for reporting.
|
||||||
|
If you’re running ≤1.4.x, please upgrade.
|
||||||
|
|
||||||
|
See also: [SECURITY.md](./SECURITY.md) for how to report vulnerabilities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
|
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|||||||
65
SECURITY.md
65
SECURITY.md
@@ -4,35 +4,58 @@
|
|||||||
|
|
||||||
We provide security fixes for the latest minor release line.
|
We provide security fixes for the latest minor release line.
|
||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
|------------|-----------|
|
|----------|-----------|
|
||||||
| v1.4.x | ✅ |
|
| v1.5.x | ✅ |
|
||||||
| < v1.4.0 | ❌ |
|
| ≤ v1.4.x | ❌ |
|
||||||
|
|
||||||
|
> Known issues in ≤ v1.4.x are fixed in **v1.5.0** and later.
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you discover a security vulnerability, please do not open a public issue. Instead, follow these steps:
|
**Please do not open a public issue.** Use one of the private channels below:
|
||||||
|
|
||||||
1. **Email Us Privately:**
|
1) **GitHub Security Advisory (preferred)**
|
||||||
Send an email to [security@filerise.net](mailto:security@filerise.net) with the subject line “[FileRise] Security Vulnerability Report”.
|
Open a private report here: <https://github.com/error311/FileRise/security/advisories/new>
|
||||||
|
|
||||||
2. **Include Details:**
|
2) **Email**
|
||||||
Provide a detailed description of the vulnerability, steps to reproduce it, and any other relevant information (e.g., affected versions, screenshots, logs).
|
Send details to **<security@filerise.net>** with subject: `[FileRise] Security Vulnerability Report`.
|
||||||
|
|
||||||
3. **Secure Communication (Optional):**
|
### What to include
|
||||||
If you wish to discuss the vulnerability securely, you can use our PGP key. You can obtain our PGP key by emailing us, and we will send it upon request.
|
|
||||||
|
|
||||||
## Disclosure Policy
|
- Affected versions (e.g., v1.4.0), component/endpoint, and impact
|
||||||
|
- Reproduction steps / PoC
|
||||||
|
- Any logs, screenshots, or crash traces
|
||||||
|
- Safe test scope used (see below)
|
||||||
|
|
||||||
- **Acknowledgement:**
|
If you’d like encrypted comms, ask for our PGP key in your first email.
|
||||||
We will acknowledge receipt of your report within 48 hours.
|
|
||||||
|
|
||||||
- **Resolution Timeline:**
|
|
||||||
We aim to fix confirmed vulnerabilities within 30 days. In cases where a delay is necessary, we will communicate updates to you directly.
|
|
||||||
|
|
||||||
- **Public Disclosure:**
|
## Coordinated Disclosure
|
||||||
After a fix is available, details of the vulnerability will be disclosed publicly in a way that does not compromise user security.
|
|
||||||
|
|
||||||
## Additional Information
|
- **Acknowledgement:** within **48 hours**
|
||||||
|
- **Triage & initial assessment:** within **7 days**
|
||||||
|
- **Fix target:** within **30 days** for high-severity issues (may vary by complexity)
|
||||||
|
- **CVE & advisory:** we publish a GitHub Security Advisory and request a CVE when appropriate.
|
||||||
|
We notify the reporter before public disclosure and credit them (unless they prefer to remain anonymous).
|
||||||
|
|
||||||
We appreciate responsible disclosure of vulnerabilities and thank all researchers who help keep FileRise secure. For any questions related to this policy, please contact us at [admin@filerise.net](mailto:admin@filerise.net).
|
## Safe-Harbor / Rules of Engagement
|
||||||
|
|
||||||
|
We support good-faith research. Please:
|
||||||
|
|
||||||
|
- Avoid privacy violations, data exfiltration, and service disruption (no DoS, spam, or brute-forcing)
|
||||||
|
- Don’t access other users’ data beyond what’s necessary to demonstrate the issue
|
||||||
|
- Don’t run automated scans against production installs you don’t own
|
||||||
|
- Follow applicable laws and make a good-faith effort to respect data and availability
|
||||||
|
|
||||||
|
If you follow these guidelines, we won’t pursue or support legal action.
|
||||||
|
|
||||||
|
## Published Advisories
|
||||||
|
|
||||||
|
- **GHSA-6p87-q9rh-95wh** — ≤ **1.3.15**: Improper ownership/permission validation allowed cross-tenant file operations.
|
||||||
|
- **GHSA-jm96-2w52-5qjj** — **v1.4.0**: Insecure folder visibility via name-based mapping and incomplete ACL checks.
|
||||||
|
|
||||||
|
Both are fixed in **v1.5.0** (ACL hardening). Thanks to **[@kiwi865](https://github.com/kiwi865)** for responsible disclosure.
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
General security questions: **<admin@filerise.net>**
|
||||||
|
|||||||
@@ -35,13 +35,12 @@ 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_BYPASS_OWNERSHIP')) define('DEFAULT_BYPASS_OWNERSHIP', false);
|
||||||
if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true);
|
if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true);
|
||||||
if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
|
if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
|
||||||
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
|
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
|
||||||
|
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
|
||||||
|
define('ACL_INHERIT_ON_CREATE', true);
|
||||||
|
|
||||||
// Encryption helpers
|
// Encryption helpers
|
||||||
function encryptData($data, $encryptionKey)
|
function encryptData($data, $encryptionKey)
|
||||||
@@ -77,16 +76,27 @@ function loadUserPermissions($username)
|
|||||||
{
|
{
|
||||||
global $encryptionKey;
|
global $encryptionKey;
|
||||||
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
||||||
if (file_exists($permissionsFile)) {
|
if (!file_exists($permissionsFile)) {
|
||||||
$content = file_get_contents($permissionsFile);
|
return false;
|
||||||
$decrypted = decryptData($content, $encryptionKey);
|
|
||||||
$json = ($decrypted !== false) ? $decrypted : $content;
|
|
||||||
$perms = json_decode($json, true);
|
|
||||||
if (is_array($perms) && isset($perms[$username])) {
|
|
||||||
return !empty($perms[$username]) ? $perms[$username] : false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
$content = file_get_contents($permissionsFile);
|
||||||
|
$decrypted = decryptData($content, $encryptionKey);
|
||||||
|
$json = ($decrypted !== false) ? $decrypted : $content;
|
||||||
|
$permsAll = json_decode($json, true);
|
||||||
|
|
||||||
|
if (!is_array($permsAll)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try exact match first, then lowercase (since we store keys lowercase elsewhere)
|
||||||
|
$uExact = (string)$username;
|
||||||
|
$uLower = strtolower($uExact);
|
||||||
|
|
||||||
|
$row = $permsAll[$uExact] ?? $permsAll[$uLower] ?? null;
|
||||||
|
|
||||||
|
// Normalize: always return an array when found, else false (to preserve current callers’ behavior)
|
||||||
|
return is_array($row) ? $row : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine HTTPS usage
|
// Determine HTTPS usage
|
||||||
@@ -96,25 +106,39 @@ $secure = ($envSecure !== false)
|
|||||||
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||||
|
|
||||||
// Choose session lifetime based on "remember me" cookie
|
// Choose session lifetime based on "remember me" cookie
|
||||||
$defaultSession = 7200; // 2 hours
|
$defaultSession = 7200; // 2 hours
|
||||||
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
||||||
$sessionLifetime = isset($_COOKIE['remember_me_token'])
|
$sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession;
|
||||||
? $persistentDays
|
|
||||||
: $defaultSession;
|
|
||||||
|
|
||||||
// Configure PHP session cookie and GC
|
|
||||||
session_set_cookie_params([
|
|
||||||
'lifetime' => $sessionLifetime,
|
|
||||||
'path' => '/',
|
|
||||||
'domain' => '', // adjust if you need a specific domain
|
|
||||||
'secure' => $secure,
|
|
||||||
'httponly' => true,
|
|
||||||
'samesite' => 'Lax'
|
|
||||||
]);
|
|
||||||
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start session idempotently:
|
||||||
|
* - If no session: set cookie params + gc_maxlifetime, then session_start().
|
||||||
|
* - If session already active: DO NOT change ini/cookie params; optionally refresh cookie expiry.
|
||||||
|
*/
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => $sessionLifetime,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => '', // adjust if you need a specific domain
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax'
|
||||||
|
]);
|
||||||
|
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
||||||
session_start();
|
session_start();
|
||||||
|
} else {
|
||||||
|
// Optionally refresh the session cookie expiry to keep the user alive
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
if ($sessionLifetime > 0) {
|
||||||
|
setcookie(session_name(), session_id(), [
|
||||||
|
'expires' => time() + $sessionLifetime,
|
||||||
|
'path' => $params['path'] ?: '/',
|
||||||
|
'domain' => $params['domain'] ?? '',
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => $params['samesite'] ?? 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF token
|
// CSRF token
|
||||||
@@ -122,8 +146,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 = [];
|
||||||
|
|||||||
5098
openapi.json.dist
5098
openapi.json.dist
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ if (isset($_GET['spec'])) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>FileRise API Docs</title>
|
<title>FileRise API Docs</title>
|
||||||
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
|
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
|
||||||
integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX"
|
integrity="sha384-70P5pmIdaQdVbxvjhrcTDv1uKcKqalZ3OHi7S2J+uzDl0PW8dO6L+pHOpm9EEjGJ"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script defer src="/js/redoc-init.js"></script>
|
<script defer src="/js/redoc-init.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,6 +1,40 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/addUser.php
|
// public/api/addUser.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/addUser.php",
|
||||||
|
* summary="Add a new user",
|
||||||
|
* description="Adds a new user to the system. In setup mode, the new user is automatically made admin.",
|
||||||
|
* operationId="addUser",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"username", "password"},
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||||
|
* @OA\Property(property="password", type="string", example="securepassword"),
|
||||||
|
* @OA\Property(property="isAdmin", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="User added successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="success", type="string", example="User added successfully")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
85
public/api/admin/acl/getGrants.php
Normal file
85
public/api/admin/acl/getGrants.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?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');
|
||||||
|
|
||||||
|
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 = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||||
|
if (is_file($aclPath)) {
|
||||||
|
$data = json_decode((string)@file_get_contents($aclPath), true);
|
||||||
|
if (is_array($data['folders'] ?? null)) {
|
||||||
|
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::explicitAll($f); // legacy + granular
|
||||||
|
|
||||||
|
$isOwner = $has($rec['owners'], $user);
|
||||||
|
$canViewAll = $isOwner || $has($rec['read'], $user);
|
||||||
|
$canViewOwn = $has($rec['read_own'], $user);
|
||||||
|
$canShare = $isOwner || $has($rec['share'], $user);
|
||||||
|
$canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user);
|
||||||
|
|
||||||
|
if ($canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner
|
||||||
|
|| $has($rec['create'],$user) || $has($rec['edit'],$user) || $has($rec['rename'],$user)
|
||||||
|
|| $has($rec['copy'],$user) || $has($rec['move'],$user) || $has($rec['delete'],$user)
|
||||||
|
|| $has($rec['extract'],$user) || $has($rec['share_file'],$user) || $has($rec['share_folder'],$user)) {
|
||||||
|
$out[$f] = [
|
||||||
|
'view' => $canViewAll,
|
||||||
|
'viewOwn' => $canViewOwn,
|
||||||
|
'write' => $has($rec['write'], $user) || $isOwner,
|
||||||
|
'manage' => $isOwner,
|
||||||
|
'share' => $canShare, // legacy
|
||||||
|
'create' => $isOwner || $has($rec['create'], $user),
|
||||||
|
'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'],$user),
|
||||||
|
'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'],$user),
|
||||||
|
'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'],$user),
|
||||||
|
'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'],$user),
|
||||||
|
'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'],$user),
|
||||||
|
'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'],$user),
|
||||||
|
'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'],$user),
|
||||||
|
'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'],$user),
|
||||||
|
'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'],$user),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);
|
||||||
121
public/api/admin/acl/saveGrants.php
Normal file
121
public/api/admin/acl/saveGrants.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?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 ---------------------------------------------------------------
|
||||||
|
function normalize_caps(array $row): array {
|
||||||
|
// booleanize known keys
|
||||||
|
$bool = function($v){ return !empty($v) && $v !== 'false' && $v !== 0; };
|
||||||
|
$k = [
|
||||||
|
'view','viewOwn','upload','manage','share',
|
||||||
|
'create','edit','rename','copy','move','delete','extract',
|
||||||
|
'shareFile','shareFolder','write'
|
||||||
|
];
|
||||||
|
$out = [];
|
||||||
|
foreach ($k as $kk) $out[$kk] = $bool($row[$kk] ?? false);
|
||||||
|
|
||||||
|
// BUSINESS RULES:
|
||||||
|
// A) Share Folder REQUIRES View (all). If shareFolder is true but view is false, force view=true.
|
||||||
|
if ($out['shareFolder'] && !$out['view']) {
|
||||||
|
$out['view'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// B) Share File requires at least View (own). If neither view nor viewOwn set, set viewOwn=true.
|
||||||
|
if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) {
|
||||||
|
$out['viewOwn'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// C) "write" does NOT imply view. It also does not imply granular here; ACL expands legacy write if present.
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize_grants_map(array $grants): array {
|
||||||
|
$out = [];
|
||||||
|
foreach ($grants as $folder => $caps) {
|
||||||
|
if (!is_string($folder)) $folder = (string)$folder;
|
||||||
|
if (!is_array($caps)) $caps = [];
|
||||||
|
$out[$folder] = normalize_caps($caps);
|
||||||
|
}
|
||||||
|
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}]}']);
|
||||||
@@ -1,6 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/admin/getConfig.php
|
// public/api/admin/getConfig.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/admin/getConfig.php",
|
||||||
|
* tags={"Admin"},
|
||||||
|
* summary="Get UI configuration",
|
||||||
|
* description="Returns a public subset for everyone; authenticated admins receive additional loginOptions fields.",
|
||||||
|
* operationId="getAdminConfig",
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Configuration loaded",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* oneOf={
|
||||||
|
* @OA\Schema(ref="#/components/schemas/AdminGetConfigPublic"),
|
||||||
|
* @OA\Schema(ref="#/components/schemas/AdminGetConfigAdmin")
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=500, description="Server error")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Retrieves the admin configuration settings and outputs JSON.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/admin/readMetadata.php
|
// public/api/admin/readMetadata.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/admin/readMetadata.php",
|
||||||
|
* summary="Read share metadata JSON",
|
||||||
|
* description="Admin-only: returns the cleaned metadata for file or folder share links.",
|
||||||
|
* tags={"Admin"},
|
||||||
|
* operationId="readMetadata",
|
||||||
|
* security={{"cookieAuth":{}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="file",
|
||||||
|
* in="query",
|
||||||
|
* required=true,
|
||||||
|
* description="Which metadata file to read",
|
||||||
|
* @OA\Schema(type="string", enum={"share_links.json","share_folder_links.json"})
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="OK",
|
||||||
|
* @OA\JsonContent(oneOf={
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ShareLinksMap"),
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ShareFolderLinksMap")
|
||||||
|
* })
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Missing or invalid file param"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden (admin only)"),
|
||||||
|
* @OA\Response(response=500, description="Corrupted JSON")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
|
||||||
// Only admins may read these
|
// Only admins may read these
|
||||||
|
|||||||
@@ -1,6 +1,45 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/admin/updateConfig.php
|
// public/api/admin/updateConfig.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/admin/updateConfig.php",
|
||||||
|
* summary="Update admin configuration",
|
||||||
|
* description="Merges the provided settings into the on-disk configuration and persists them. Requires an authenticated admin session and a valid CSRF token. When OIDC is enabled (disableOIDCLogin=false), `providerUrl`, `redirectUri`, and `clientId` are required and must be HTTPS (HTTP allowed only for localhost).",
|
||||||
|
* operationId="updateAdminConfig",
|
||||||
|
* tags={"Admin"},
|
||||||
|
* security={ {{"cookieAuth": {}, "CsrfHeader": {}}} },
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/AdminUpdateConfigRequest")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Configuration updated",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/SimpleSuccess")
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Validation error (e.g., bad authHeaderName, missing OIDC fields when enabled, or negative upload limit)",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/SimpleError")
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Unauthorized access or invalid CSRF token",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/SimpleError")
|
||||||
|
* // or: ref to the reusable response
|
||||||
|
* // ref="#/components/responses/Forbidden"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=500,
|
||||||
|
* description="Server error while loading or saving configuration",
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/SimpleError")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,52 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/auth/auth.php
|
// public/api/auth/auth.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/auth/auth.php",
|
||||||
|
* summary="Authenticate user",
|
||||||
|
* description="Handles user authentication via OIDC or form-based credentials. For OIDC flows, processes callbacks; otherwise, performs standard authentication with optional TOTP verification.",
|
||||||
|
* operationId="authUser",
|
||||||
|
* tags={"Auth"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"username", "password"},
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||||
|
* @OA\Property(property="password", type="string", example="secretpassword"),
|
||||||
|
* @OA\Property(property="remember_me", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="totp_code", type="string", example="123456")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Login successful; returns user info and status",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="string", example="ok"),
|
||||||
|
* @OA\Property(property="success", type="string", example="Login successful"),
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||||
|
* @OA\Property(property="isAdmin", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request (e.g., missing credentials)"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized (e.g., invalid credentials, too many attempts)"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=429,
|
||||||
|
* description="Too many failed login attempts"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Handles user authentication via OIDC or form-based login.
|
||||||
|
*
|
||||||
|
* @return void Redirects on success or outputs JSON error.
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/auth/checkAuth.php
|
// public/api/auth/checkAuth.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/auth/checkAuth.php",
|
||||||
|
* summary="Check authentication status",
|
||||||
|
* operationId="checkAuth",
|
||||||
|
* tags={"Auth"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Authenticated status or setup flag",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* oneOf={
|
||||||
|
* @OA\Schema(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="authenticated", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="isAdmin", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="totp_enabled", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||||
|
* @OA\Property(property="folderOnly", type="boolean", example=false)
|
||||||
|
* ),
|
||||||
|
* @OA\Schema(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="setup", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/auth/login_basic.php
|
// public/api/auth/login_basic.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/auth/login_basic.php",
|
||||||
|
* summary="Authenticate using HTTP Basic Authentication",
|
||||||
|
* description="Performs HTTP Basic authentication. If credentials are missing, sends a 401 response prompting for Basic auth. On valid credentials, optionally handles TOTP verification and finalizes session login.",
|
||||||
|
* operationId="loginBasic",
|
||||||
|
* tags={"Auth"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Login successful; redirects to index.html",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="success", type="string", example="Login successful")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized due to missing credentials or invalid credentials."
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Handles HTTP Basic authentication (with optional TOTP) and logs the user in.
|
||||||
|
*
|
||||||
|
* @return void Redirects on success or sends a 401 header.
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/auth/logout.php
|
// public/api/auth/logout.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/auth/logout.php",
|
||||||
|
* summary="Logout user",
|
||||||
|
* description="Clears the session, removes persistent login tokens, and redirects the user to the login page.",
|
||||||
|
* operationId="logoutUser",
|
||||||
|
* tags={"Auth"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=302,
|
||||||
|
* description="Redirects to the login page with a logout flag."
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Logs the user out by clearing session data, removing persistent tokens, and destroying the session.
|
||||||
|
*
|
||||||
|
* @return void Redirects to index.html with a logout flag.
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/auth/token.php
|
// public/api/auth/token.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/auth/token.php",
|
||||||
|
* summary="Retrieve CSRF token and share URL",
|
||||||
|
* description="Returns the current CSRF token along with the configured share URL.",
|
||||||
|
* operationId="getToken",
|
||||||
|
* tags={"Auth"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="CSRF token and share URL",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="csrf_token", type="string", example="0123456789abcdef..."),
|
||||||
|
* @OA\Property(property="share_url", type="string", example="https://yourdomain.com/share.php")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Returns the CSRF token and share URL.
|
||||||
|
*
|
||||||
|
* @return void Outputs the JSON response.
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,44 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/changePassword.php
|
// public/api/changePassword.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/changePassword.php",
|
||||||
|
* summary="Change user password",
|
||||||
|
* description="Allows an authenticated user to change their password by verifying the old password and updating to a new one.",
|
||||||
|
* operationId="changePassword",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"oldPassword", "newPassword", "confirmPassword"},
|
||||||
|
* @OA\Property(property="oldPassword", type="string", example="oldpass123"),
|
||||||
|
* @OA\Property(property="newPassword", type="string", example="newpass456"),
|
||||||
|
* @OA\Property(property="confirmPassword", type="string", example="newpass456")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Password updated successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="success", type="string", example="Password updated successfully.")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Invalid CSRF token"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/copyFiles.php
|
// public/api/file/copyFiles.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/copyFiles.php",
|
||||||
|
* summary="Copy files between folders",
|
||||||
|
* description="Requires read access on source and write access on destination. Enforces folder scope and ownership.",
|
||||||
|
* operationId="copyFiles",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="X-CSRF-Token", in="header", required=true,
|
||||||
|
* description="CSRF token from the current session",
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"source","destination","files"},
|
||||||
|
* @OA\Property(property="source", type="string", example="root"),
|
||||||
|
* @OA\Property(property="destination", type="string", example="userA/projects"),
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"report.pdf","notes.txt"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Copy result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid request or folder name"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/createFile.php
|
// public/api/file/createFile.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/createFile.php",
|
||||||
|
* summary="Create an empty file",
|
||||||
|
* description="Requires write access on the target folder. Enforces folder-only scope.",
|
||||||
|
* operationId="createFile",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","name"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="name", type="string", example="new.txt")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Creation result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/createShareLink.php
|
// public/api/file/createShareLink.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/createShareLink.php",
|
||||||
|
* summary="Create a share link for a file",
|
||||||
|
* description="Requires share permission on the folder. Non-admins must own the file unless bypassOwnership.",
|
||||||
|
* operationId="createShareLink",
|
||||||
|
* tags={"Shares"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","file"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="file", type="string", example="invoice.pdf"),
|
||||||
|
* @OA\Property(property="expirationValue", type="integer", example=60),
|
||||||
|
* @OA\Property(property="expirationUnit", type="string", enum={"seconds","minutes","hours","days"}, example="minutes"),
|
||||||
|
* @OA\Property(property="password", type="string", example="")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Share link created",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="token", type="string", example="abc123"),
|
||||||
|
* @OA\Property(property="url", type="string", example="/api/file/share.php?token=abc123"),
|
||||||
|
* @OA\Property(property="expires", type="integer", example=1700000000)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/deleteFiles.php
|
// public/api/file/deleteFiles.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/deleteFiles.php",
|
||||||
|
* summary="Delete files to Trash",
|
||||||
|
* description="Requires write access on the folder and (for non-admins) ownership of the files.",
|
||||||
|
* operationId="deleteFiles",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="X-CSRF-Token", in="header", required=true,
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","files"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"old.docx","draft.md"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Delete result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/deleteShareLink.php",
|
||||||
|
* summary="Delete a share link by token",
|
||||||
|
* description="Deletes a share token. NOTE: Current implementation does not require authentication.",
|
||||||
|
* operationId="deleteShareLink",
|
||||||
|
* tags={"Shares"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"token"},
|
||||||
|
* @OA\Property(property="token", type="string", example="abc123")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Deletion result (success or not found)")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/deleteTrashFiles.php
|
// public/api/file/deleteTrashFiles.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/deleteTrashFiles.php",
|
||||||
|
* summary="Permanently delete Trash items (admin only)",
|
||||||
|
* operationId="deleteTrashFiles",
|
||||||
|
* tags={"Trash"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* oneOf={
|
||||||
|
* @OA\Schema(
|
||||||
|
* required={"deleteAll"},
|
||||||
|
* @OA\Property(property="deleteAll", type="boolean", example=true)
|
||||||
|
* ),
|
||||||
|
* @OA\Schema(
|
||||||
|
* required={"files"},
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"trash/abc","trash/def"})
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Deletion result (model-defined)"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Admin only"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/download.php
|
// public/api/file/download.php
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/download.php",
|
||||||
|
* summary="Download a file",
|
||||||
|
* description="Requires view access (or own-only with ownership). Streams the file with appropriate Content-Type.",
|
||||||
|
* operationId="downloadFile",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="folder", in="query", required=true, @OA\Schema(type="string"), example="root"),
|
||||||
|
* @OA\Parameter(name="file", in="query", required=true, @OA\Schema(type="string"), example="photo.jpg"),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Binary file",
|
||||||
|
* content={
|
||||||
|
* "application/octet-stream": @OA\MediaType(
|
||||||
|
* mediaType="application/octet-stream",
|
||||||
|
* @OA\Schema(type="string", format="binary")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid folder/file"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,41 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/downloadZip.php
|
// public/api/file/downloadZip.php
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/downloadZip.php",
|
||||||
|
* summary="Download multiple files as a ZIP",
|
||||||
|
* description="Requires view access (or own-only with ownership). May be gated by account flag.",
|
||||||
|
* operationId="downloadZip",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","files"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"a.jpg","b.png"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="ZIP archive",
|
||||||
|
* content={
|
||||||
|
* "application/zip": @OA\MediaType(
|
||||||
|
* mediaType="application/zip",
|
||||||
|
* @OA\Schema(type="string", format="binary")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/extractZip.php
|
// public/api/file/extractZip.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/extractZip.php",
|
||||||
|
* summary="Extract ZIP file(s) into a folder",
|
||||||
|
* description="Requires write access on the target folder.",
|
||||||
|
* operationId="extractZip",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","files"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"archive.zip"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Extraction result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/getFileList.php
|
// public/api/file/getFileList.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/getFileList.php",
|
||||||
|
* summary="List files in a folder",
|
||||||
|
* description="Requires view access (full) or read_own (own-only results).",
|
||||||
|
* operationId="getFileList",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="folder", in="query", required=true, @OA\Schema(type="string"), example="root"),
|
||||||
|
* @OA\Response(response=200, description="Listing result (model-defined JSON)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid folder"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/getFileTag.php
|
// public/api/file/getFileTag.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/getFileTags.php",
|
||||||
|
* summary="Get global file tags",
|
||||||
|
* description="Returns tag metadata (no auth in current implementation).",
|
||||||
|
* operationId="getFileTags",
|
||||||
|
* tags={"Tags"},
|
||||||
|
* @OA\Response(response=200, description="Tags map (model-defined JSON)")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/getShareLinks.php",
|
||||||
|
* summary="Get (raw) share links file",
|
||||||
|
* description="Returns the full share links JSON (no auth in current implementation).",
|
||||||
|
* operationId="getShareLinks",
|
||||||
|
* tags={"Shares"},
|
||||||
|
* @OA\Response(response=200, description="Share links (model-defined JSON)")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/getTrashItems.php
|
// public/api/file/getTrashItems.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/getTrashItems.php",
|
||||||
|
* summary="List items in Trash (admin only)",
|
||||||
|
* operationId="getTrashItems",
|
||||||
|
* tags={"Trash"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Response(response=200, description="Trash contents (model-defined JSON)"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Admin only"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/moveFiles.php
|
// public/api/file/moveFiles.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/moveFiles.php",
|
||||||
|
* operationId="moveFiles",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth":{}}},
|
||||||
|
* @OA\RequestBody(ref="#/components/requestBodies/MoveFilesRequest"),
|
||||||
|
* @OA\Response(response=200, description="Moved"),
|
||||||
|
* @OA\Response(response=400, description="Bad Request"),
|
||||||
|
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||||
|
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/renameFile.php
|
// public/api/file/renameFile.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/file/renameFile.php",
|
||||||
|
* summary="Rename a file",
|
||||||
|
* description="Requires write access; non-admins must own the file.",
|
||||||
|
* operationId="renameFile",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","oldName","newName"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="oldName", type="string", example="old.pdf"),
|
||||||
|
* @OA\Property(property="newName", type="string", example="new.pdf")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Rename result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/restoreFiles.php
|
// public/api/file/restoreFiles.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/restoreFiles.php",
|
||||||
|
* summary="Restore files from Trash (admin only)",
|
||||||
|
* operationId="restoreFiles",
|
||||||
|
* tags={"Trash"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"files"},
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"), example={"trash/12345.json"})
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Restore result (model-defined)"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Admin only"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/saveFile.php
|
// public/api/file/saveFile.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/file/saveFile.php",
|
||||||
|
* summary="Create or overwrite a file’s content",
|
||||||
|
* description="Requires write access. Overwrite enforces ownership for non-admins. Certain executable extensions are denied.",
|
||||||
|
* operationId="saveFile",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","fileName","content"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="fileName", type="string", example="readme.txt"),
|
||||||
|
* @OA\Property(property="content", type="string", example="Hello world")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Save result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input or disallowed extension"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/saveFileTag.php
|
// public/api/file/saveFileTag.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/file/saveFileTag.php",
|
||||||
|
* summary="Save tags for a file (or delete one)",
|
||||||
|
* description="Requires write access and (for non-admins) ownership when modifying.",
|
||||||
|
* operationId="saveFileTag",
|
||||||
|
* tags={"Tags"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder","file"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="root"),
|
||||||
|
* @OA\Property(property="file", type="string", example="doc.md"),
|
||||||
|
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"work","urgent"}),
|
||||||
|
* @OA\Property(property="deleteGlobal", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="tagToDelete", type="string", nullable=true, example=null)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Save result (model-defined)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Internal error")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/file/share.php
|
// public/api/file/share.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/share.php",
|
||||||
|
* summary="Open a shared file by token",
|
||||||
|
* description="If the link is password-protected and no password is supplied, an HTML password form is returned. Otherwise the file is streamed.",
|
||||||
|
* operationId="shareFile",
|
||||||
|
* tags={"Shares"},
|
||||||
|
* @OA\Parameter(name="token", in="query", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\Parameter(name="pass", in="query", required=false, @OA\Schema(type="string")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Binary file (or HTML password form when missing password)",
|
||||||
|
* content={
|
||||||
|
* "application/octet-stream": @OA\MediaType(
|
||||||
|
* mediaType="application/octet-stream",
|
||||||
|
* @OA\Schema(type="string", format="binary")
|
||||||
|
* ),
|
||||||
|
* "text/html": @OA\MediaType(mediaType="text/html")
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Missing token / invalid input"),
|
||||||
|
* @OA\Response(response=403, description="Expired or invalid password"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
|||||||
238
public/api/folder/capabilities.php
Normal file
238
public/api/folder/capabilities.php
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/folder/capabilities.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/folder/capabilities.php",
|
||||||
|
* summary="Get effective capabilities for the current user in a folder",
|
||||||
|
* description="Computes the caller's capabilities for a given folder by combining account flags (readOnly/disableUpload), ACL grants (read/write/share), and the user-folder-only scope. Returns booleans indicating what the user can do.",
|
||||||
|
* operationId="getFolderCapabilities",
|
||||||
|
* tags={"Folders"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="folder",
|
||||||
|
* in="query",
|
||||||
|
* required=false,
|
||||||
|
* description="Target folder path. Defaults to 'root'. Supports nested paths like 'team/reports'.",
|
||||||
|
* @OA\Schema(type="string"),
|
||||||
|
* example="projects/acme"
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Capabilities computed successfully.",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* required={"user","folder","isAdmin","flags","canView","canUpload","canCreate","canRename","canDelete","canMoveIn","canShare"},
|
||||||
|
* @OA\Property(property="user", type="string", example="alice"),
|
||||||
|
* @OA\Property(property="folder", type="string", example="projects/acme"),
|
||||||
|
* @OA\Property(property="isAdmin", type="boolean", example=false),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="flags",
|
||||||
|
* type="object",
|
||||||
|
* required={"folderOnly","readOnly","disableUpload"},
|
||||||
|
* @OA\Property(property="folderOnly", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="readOnly", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="disableUpload", type="boolean", example=false)
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="owner", type="string", nullable=true, example="alice"),
|
||||||
|
* @OA\Property(property="canView", type="boolean", example=true, description="User can view items in this folder."),
|
||||||
|
* @OA\Property(property="canUpload", type="boolean", example=true, description="User can upload/edit/rename/move/delete items (i.e., WRITE)."),
|
||||||
|
* @OA\Property(property="canCreate", type="boolean", example=true, description="User can create subfolders here."),
|
||||||
|
* @OA\Property(property="canRename", type="boolean", example=true, description="User can rename items here."),
|
||||||
|
* @OA\Property(property="canDelete", type="boolean", example=true, description="User can delete items here."),
|
||||||
|
* @OA\Property(property="canMoveIn", type="boolean", example=true, description="User can move items into this folder."),
|
||||||
|
* @OA\Property(property="canShare", type="boolean", example=false, description="User can create share links for this folder.")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid folder name."),
|
||||||
|
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
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 isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
|
||||||
|
$f = ACL::normalizeFolder($folder);
|
||||||
|
// direct owner
|
||||||
|
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||||
|
// ancestor owner
|
||||||
|
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
|
||||||
|
$pos = strrpos($f, '/');
|
||||||
|
if ($pos === false) break;
|
||||||
|
$f = substr($f, 0, $pos);
|
||||||
|
if ($f === '' || strcasecmp($f, 'root') === 0) break;
|
||||||
|
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* folder-only scope:
|
||||||
|
* - Admins: always in scope
|
||||||
|
* - Non folder-only accounts: always in scope
|
||||||
|
* - Folder-only accounts: in scope iff:
|
||||||
|
* - folder == username OR subpath of username, OR
|
||||||
|
* - user is owner of this folder (or any ancestor)
|
||||||
|
*/
|
||||||
|
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
|
||||||
|
if ($isAdmin) return true;
|
||||||
|
//$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
|
||||||
|
//if (!$folderOnly) return true;
|
||||||
|
|
||||||
|
$f = ACL::normalizeFolder($folder);
|
||||||
|
if ($f === 'root' || $f === '') {
|
||||||
|
// folder-only users cannot act on root unless they own a subfolder (handled below)
|
||||||
|
return isOwnerOrAncestorOwner($u, $perms, $f);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($f === $u || str_starts_with($f, $u . '/')) return true;
|
||||||
|
|
||||||
|
// Treat ownership as in-scope
|
||||||
|
return isOwnerOrAncestorOwner($u, $perms, $f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- inputs ---
|
||||||
|
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||||
|
|
||||||
|
// validate folder path
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- user + flags ---
|
||||||
|
$perms = loadPermsFor($username);
|
||||||
|
$isAdmin = ACL::isAdmin($perms);
|
||||||
|
$readOnly = !empty($perms['readOnly']);
|
||||||
|
$disableUp = !empty($perms['disableUpload']);
|
||||||
|
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
||||||
|
|
||||||
|
// --- ACL base abilities ---
|
||||||
|
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
|
||||||
|
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
|
||||||
|
$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder);
|
||||||
|
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
|
||||||
|
|
||||||
|
$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder);
|
||||||
|
|
||||||
|
// granular base
|
||||||
|
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
|
||||||
|
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
|
||||||
|
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
|
||||||
|
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
|
||||||
|
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
|
||||||
|
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
|
||||||
|
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
|
||||||
|
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
|
||||||
|
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
|
||||||
|
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
|
||||||
|
|
||||||
|
// --- Apply scope + flags to effective UI actions ---
|
||||||
|
$canView = $canViewBase && $inScope; // keep scope for folder-only
|
||||||
|
$canUpload = $gUploadBase && !$readOnly && !$disableUpload && $inScope;
|
||||||
|
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
|
||||||
|
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
|
||||||
|
$canDelete = $gDeleteBase && !$readOnly && $inScope;
|
||||||
|
// Destination can receive items if user can create/write (or manage) here
|
||||||
|
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
|
||||||
|
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
|
||||||
|
$canMoveIn = $canReceive;
|
||||||
|
$canEdit = $gEditBase && !$readOnly && $inScope;
|
||||||
|
$canCopy = $gCopyBase && !$readOnly && $inScope;
|
||||||
|
$canExtract = $gExtractBase && !$readOnly && $inScope;
|
||||||
|
|
||||||
|
// Sharing respects scope; optionally also gate on readOnly
|
||||||
|
$canShare = $canShareBase && $inScope; // legacy umbrella
|
||||||
|
$canShareFileEff = $gShareFile && $inScope;
|
||||||
|
$canShareFoldEff = $gShareFolder && $inScope;
|
||||||
|
|
||||||
|
// never allow destructive ops on root
|
||||||
|
$isRoot = ($folder === 'root');
|
||||||
|
if ($isRoot) {
|
||||||
|
$canRename = false;
|
||||||
|
$canDelete = false;
|
||||||
|
$canShareFoldEff = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$owner = null;
|
||||||
|
try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'user' => $username,
|
||||||
|
'folder' => $folder,
|
||||||
|
'isAdmin' => $isAdmin,
|
||||||
|
'flags' => [
|
||||||
|
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
||||||
|
'readOnly' => $readOnly,
|
||||||
|
'disableUpload' => $disableUp,
|
||||||
|
],
|
||||||
|
'owner' => $owner,
|
||||||
|
|
||||||
|
// viewing
|
||||||
|
'canView' => $canView,
|
||||||
|
'canViewOwn' => $canViewOwn,
|
||||||
|
|
||||||
|
// write-ish
|
||||||
|
'canUpload' => $canUpload,
|
||||||
|
'canCreate' => $canCreate,
|
||||||
|
'canRename' => $canRename,
|
||||||
|
'canDelete' => $canDelete,
|
||||||
|
'canMoveIn' => $canMoveIn,
|
||||||
|
'canEdit' => $canEdit,
|
||||||
|
'canCopy' => $canCopy,
|
||||||
|
'canExtract' => $canExtract,
|
||||||
|
|
||||||
|
// sharing
|
||||||
|
'canShare' => $canShare, // legacy
|
||||||
|
'canShareFile' => $canShareFileEff,
|
||||||
|
'canShareFolder' => $canShareFoldEff,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
@@ -1,6 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/createFolder.php
|
// public/api/folder/createFolder.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/folder/createFolder.php",
|
||||||
|
* summary="Create a new folder",
|
||||||
|
* description="Requires authentication, CSRF token, and write access to the parent folder. Seeds ACL owner.",
|
||||||
|
* operationId="createFolder",
|
||||||
|
* tags={"Folders"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="X-CSRF-Token", in="header", required=true,
|
||||||
|
* description="CSRF token from the current session",
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folderName"},
|
||||||
|
* @OA\Property(property="folderName", type="string", example="reports"),
|
||||||
|
* @OA\Property(property="parent", type="string", nullable=true, example="root",
|
||||||
|
* description="Parent folder (default root)")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Creation result (model-defined JSON)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=405, description="Method not allowed")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/createShareFolderLink.php
|
// public/api/folder/createShareFolderLink.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/folder/createShareFolderLink.php",
|
||||||
|
* summary="Create a share link for a folder",
|
||||||
|
* description="Requires authentication, CSRF token, and share permission. Non-admins must own the folder (unless bypass) and cannot share root.",
|
||||||
|
* operationId="createShareFolderLink",
|
||||||
|
* tags={"Shared Folders"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="team/reports"),
|
||||||
|
* @OA\Property(property="expirationValue", type="integer", example=60),
|
||||||
|
* @OA\Property(property="expirationUnit", type="string", enum={"seconds","minutes","hours","days"}, example="minutes"),
|
||||||
|
* @OA\Property(property="password", type="string", example=""),
|
||||||
|
* @OA\Property(property="allowUpload", type="integer", enum={0,1}, example=0)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Share folder link created",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="token", type="string", example="sf_abc123"),
|
||||||
|
* @OA\Property(property="url", type="string", example="/api/folder/shareFolder.php?token=sf_abc123"),
|
||||||
|
* @OA\Property(property="expires", type="integer", example=1700000000)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/deleteFolder.php
|
// public/api/folder/deleteFolder.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/folder/deleteFolder.php",
|
||||||
|
* summary="Delete a folder",
|
||||||
|
* description="Requires authentication, CSRF token, write scope, and (for non-admins) folder ownership.",
|
||||||
|
* operationId="deleteFolder",
|
||||||
|
* tags={"Folders"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="userA/reports")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Deletion result (model-defined JSON)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=405, description="Method not allowed")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/folder/deleteShareFolderLink.php",
|
||||||
|
* summary="Delete a shared-folder link by token (admin only)",
|
||||||
|
* description="Requires authentication, CSRF token, and admin privileges.",
|
||||||
|
* operationId="deleteShareFolderLink",
|
||||||
|
* tags={"Shared Folders","Admin"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"token"},
|
||||||
|
* @OA\Property(property="token", type="string", example="sf_abc123")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Deleted"),
|
||||||
|
* @OA\Response(response=400, description="No token provided"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Admin only"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/downloadSharedFile.php
|
// public/api/folder/downloadSharedFile.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/folder/downloadSharedFile.php",
|
||||||
|
* summary="Download a file from a shared folder (by token)",
|
||||||
|
* description="Public endpoint; validates token and file name, then streams the file.",
|
||||||
|
* operationId="downloadSharedFile",
|
||||||
|
* tags={"Shared Folders"},
|
||||||
|
* @OA\Parameter(name="token", in="query", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\Parameter(name="file", in="query", required=true, @OA\Schema(type="string"), example="report.pdf"),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Binary file",
|
||||||
|
* content={
|
||||||
|
* "application/octet-stream": @OA\MediaType(
|
||||||
|
* mediaType="application/octet-stream",
|
||||||
|
* @OA\Schema(type="string", format="binary")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/getFolderList.php
|
// public/api/folder/getFolderList.php
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/folder/getFolderList.php",
|
||||||
|
* summary="List folders (optionally under a parent)",
|
||||||
|
* description="Requires authentication. Non-admins see folders for which they have full view or own-only access.",
|
||||||
|
* operationId="getFolderList",
|
||||||
|
* tags={"Folders"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="folder", in="query", required=false,
|
||||||
|
* description="Parent folder to include and descend (default all); use 'root' for top-level",
|
||||||
|
* @OA\Schema(type="string"), example="root"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="List of folders",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="folder", type="string", example="team/reports"),
|
||||||
|
* @OA\Property(property="fileCount", type="integer", example=12),
|
||||||
|
* @OA\Property(property="metadataFile", type="string", example="/path/to/meta.json")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid folder"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/folder/getShareFolderLinks.php",
|
||||||
|
* summary="List active shared-folder links (admin only)",
|
||||||
|
* description="Returns all non-expired shared-folder links. Admin-only.",
|
||||||
|
* operationId="getShareFolderLinks",
|
||||||
|
* tags={"Shared Folders","Admin"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Response(response=200, description="Active share-folder links (model-defined JSON)"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Admin only")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/renameFolder.php
|
// public/api/folder/renameFolder.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/folder/renameFolder.php",
|
||||||
|
* summary="Rename or move a folder",
|
||||||
|
* description="Requires authentication, CSRF token, scope checks on old and new paths, and (for non-admins) ownership of the source folder.",
|
||||||
|
* operationId="renameFolder",
|
||||||
|
* tags={"Folders"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="X-CSRF-Token", in="header", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"oldFolder","newFolder"},
|
||||||
|
* @OA\Property(property="oldFolder", type="string", example="team/q1"),
|
||||||
|
* @OA\Property(property="newFolder", type="string", example="team/quarter-1")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=200, description="Rename result (model-defined JSON)"),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=405, description="Method not allowed")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/shareFolder.php
|
// public/api/folder/shareFolder.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/folder/shareFolder.php",
|
||||||
|
* summary="Open a shared folder by token (HTML UI)",
|
||||||
|
* description="If the share is password-protected and no password is supplied, an HTML password form is returned. Otherwise renders an HTML listing with optional upload form.",
|
||||||
|
* operationId="shareFolder",
|
||||||
|
* tags={"Shared Folders"},
|
||||||
|
* @OA\Parameter(name="token", in="query", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\Parameter(name="pass", in="query", required=false, @OA\Schema(type="string")),
|
||||||
|
* @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer", minimum=1), example=1),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="HTML page (password form or folder listing)",
|
||||||
|
* content={"text/html": @OA\MediaType(mediaType="text/html")}
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Missing/invalid token"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden or wrong password")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,33 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/uploadToSharedFolder.php
|
// public/api/folder/uploadToSharedFolder.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/folder/uploadToSharedFolder.php",
|
||||||
|
* summary="Upload a file into a shared folder (by token)",
|
||||||
|
* description="Public form-upload endpoint. Only allowed when the share link has uploads enabled. On success responds with a redirect to the share page.",
|
||||||
|
* operationId="uploadToSharedFolder",
|
||||||
|
* tags={"Shared Folders"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* content={
|
||||||
|
* "multipart/form-data": @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
* @OA\Schema(
|
||||||
|
* type="object",
|
||||||
|
* required={"token","fileToUpload"},
|
||||||
|
* @OA\Property(property="token", type="string", description="Share token"),
|
||||||
|
* @OA\Property(property="fileToUpload", type="string", format="binary", description="File to upload")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=302, description="Redirect to /api/folder/shareFolder.php?token=..."),
|
||||||
|
* @OA\Response(response=400, description="Upload error or invalid input"),
|
||||||
|
* @OA\Response(response=405, description="Method not allowed")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/getUserPermissions.php
|
// public/api/getUserPermissions.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/getUserPermissions.php",
|
||||||
|
* summary="Retrieve user permissions",
|
||||||
|
* description="Returns the permissions for the current user, or all permissions if the user is an admin.",
|
||||||
|
* operationId="getUserPermissions",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Successful response with user permissions",
|
||||||
|
* @OA\JsonContent(type="object")
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/getUsers.php
|
// public/api/getUsers.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/getUsers.php",
|
||||||
|
* summary="Retrieve a list of users",
|
||||||
|
* description="Returns a JSON array of users. Only available to authenticated admin users.",
|
||||||
|
* operationId="getUsers",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Successful response with an array of users",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||||
|
* @OA\Property(property="role", type="string", example="admin")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized: the user is not authenticated or is not an admin"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,29 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/profile/getCurrentUser.php",
|
||||||
|
* operationId="getCurrentUser",
|
||||||
|
* tags={"Users"},
|
||||||
|
* security={{"cookieAuth":{}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Current user",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* required={"username","isAdmin","totp_enabled","profile_picture"},
|
||||||
|
* @OA\Property(property="username", type="string", example="ryan"),
|
||||||
|
* @OA\Property(property="isAdmin", type="boolean"),
|
||||||
|
* @OA\Property(property="totp_enabled", type="boolean"),
|
||||||
|
* @OA\Property(property="profile_picture", type="string", example="/uploads/profile_pics/ryan.png")
|
||||||
|
* // If you had an array: @OA\Property(property="roles", type="array", @OA\Items(type="string"))
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,57 @@
|
|||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/profile/uploadPicture.php",
|
||||||
|
* summary="Upload or replace the current user's profile picture",
|
||||||
|
* description="Accepts a single image file (JPEG, PNG, or GIF) up to 2 MB. Requires a valid session cookie and CSRF token.",
|
||||||
|
* operationId="uploadProfilePicture",
|
||||||
|
* tags={"Users"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="X-CSRF-Token",
|
||||||
|
* in="header",
|
||||||
|
* required=true,
|
||||||
|
* description="Anti-CSRF token associated with the current session.",
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
* @OA\Schema(
|
||||||
|
* required={"profile_picture"},
|
||||||
|
* @OA\Property(
|
||||||
|
* property="profile_picture",
|
||||||
|
* type="string",
|
||||||
|
* format="binary",
|
||||||
|
* description="JPEG, PNG, or GIF image. Max size: 2 MB."
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Profile picture updated.",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* required={"success","url"},
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="url", type="string", example="/uploads/profile_pics/alice_9f3c2e1a8bcd.png")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="No file uploaded, invalid file type, or file too large."),
|
||||||
|
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||||
|
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||||
|
* @OA\Response(response=500, description="Server error while saving the picture.")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
// Always JSON, even on PHP notices
|
// Always JSON, even on PHP notices
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/removeUser.php
|
// public/api/removeUser.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/api/removeUser.php",
|
||||||
|
* summary="Remove a user",
|
||||||
|
* description="Removes the specified user from the system. Cannot remove the currently logged-in user.",
|
||||||
|
* operationId="removeUser",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"username"},
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="User removed successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="success", type="string", example="User removed successfully")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Invalid CSRF token"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/totp_disable.php
|
// public/api/totp_disable.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/totp_disable.php",
|
||||||
|
* summary="Disable TOTP for the authenticated user",
|
||||||
|
* description="Clears the TOTP secret from the users file for the current user.",
|
||||||
|
* operationId="disableTOTP",
|
||||||
|
* tags={"TOTP"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="TOTP disabled successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="TOTP disabled successfully.")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Not authenticated or invalid CSRF token"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=500,
|
||||||
|
* description="Failed to disable TOTP"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|||||||
@@ -1,6 +1,46 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/totp_recover.php
|
// public/api/totp_recover.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/totp_recover.php",
|
||||||
|
* summary="Recover TOTP",
|
||||||
|
* description="Verifies a recovery code to disable TOTP and finalize login.",
|
||||||
|
* operationId="recoverTOTP",
|
||||||
|
* tags={"TOTP"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"recovery_code"},
|
||||||
|
* @OA\Property(property="recovery_code", type="string", example="ABC123DEF456")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Recovery successful",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="string", example="ok")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Invalid input or recovery code"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Invalid CSRF token"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=405,
|
||||||
|
* description="Method not allowed"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=429,
|
||||||
|
* description="Too many attempts"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/totp_saveCode.php
|
// public/api/totp_saveCode.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/totp_saveCode.php",
|
||||||
|
* summary="Generate and save a new TOTP recovery code",
|
||||||
|
* description="Generates a new TOTP recovery code for the authenticated user, stores its hash, and returns the plain text recovery code.",
|
||||||
|
* operationId="totpSaveCode",
|
||||||
|
* tags={"TOTP"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Recovery code generated successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="string", example="ok"),
|
||||||
|
* @OA\Property(property="recoveryCode", type="string", example="ABC123DEF456")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Invalid CSRF token or unauthorized"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=405,
|
||||||
|
* description="Method not allowed"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/totp_setup.php
|
// public/api/totp_setup.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/totp_setup.php",
|
||||||
|
* summary="Set up TOTP and generate a QR code",
|
||||||
|
* description="Generates (or retrieves) the TOTP secret for the user and builds a QR code image for scanning.",
|
||||||
|
* operationId="setupTOTP",
|
||||||
|
* tags={"TOTP"},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="QR code image for TOTP setup",
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="image/png"
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Unauthorized or invalid CSRF token"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=500,
|
||||||
|
* description="Server error"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|||||||
@@ -1,6 +1,43 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/totp_verify.php
|
// public/api/totp_verify.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/totp_verify.php",
|
||||||
|
* summary="Verify TOTP code",
|
||||||
|
* description="Verifies a TOTP code and completes login for pending users or validates TOTP for setup verification.",
|
||||||
|
* operationId="verifyTOTP",
|
||||||
|
* tags={"TOTP"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"totp_code"},
|
||||||
|
* @OA\Property(property="totp_code", type="string", example="123456")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="TOTP successfully verified",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="status", type="string", example="ok"),
|
||||||
|
* @OA\Property(property="message", type="string", example="Login successful")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request (e.g., invalid input)"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Not authenticated or invalid CSRF token"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=429,
|
||||||
|
* description="Too many attempts. Try again later."
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/updateUserPanel.php
|
// public/api/updateUserPanel.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/updateUserPanel.php",
|
||||||
|
* summary="Update user panel settings",
|
||||||
|
* description="Updates user panel settings by disabling TOTP when not enabled. Accessible to authenticated users.",
|
||||||
|
* operationId="updateUserPanel",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"totp_enabled"},
|
||||||
|
* @OA\Property(property="totp_enabled", type="boolean", example=false)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="User panel updated successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="success", type="string", example="User panel updated: TOTP disabled")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Invalid CSRF token"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,52 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/updateUserPermissions.php
|
// public/api/updateUserPermissions.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/updateUserPermissions.php",
|
||||||
|
* summary="Update user permissions",
|
||||||
|
* description="Updates permissions for users. Only available to authenticated admin users.",
|
||||||
|
* operationId="updateUserPermissions",
|
||||||
|
* tags={"Users"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"permissions"},
|
||||||
|
* @OA\Property(
|
||||||
|
* property="permissions",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||||
|
* @OA\Property(property="folderOnly", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="readOnly", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="disableUpload", type="boolean", example=false)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="User permissions updated successfully",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* @OA\Property(property="success", type="string", example="User permissions updated successfully.")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=401,
|
||||||
|
* description="Unauthorized"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=403,
|
||||||
|
* description="Invalid CSRF token"
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=400,
|
||||||
|
* description="Bad Request"
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/upload/removeChunks.php
|
// public/api/upload/removeChunks.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/upload/removeChunks.php",
|
||||||
|
* summary="Remove temporary chunk directory",
|
||||||
|
* description="Deletes the temporary directory used for a chunked upload. Requires a valid CSRF token in the form field.",
|
||||||
|
* operationId="removeChunks",
|
||||||
|
* tags={"Uploads"},
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"folder"},
|
||||||
|
* @OA\Property(property="folder", type="string", example="resumable_myupload123"),
|
||||||
|
* @OA\Property(property="csrf_token", type="string", description="CSRF token for this session")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="Removal result",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="Temporary folder removed.")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Invalid input"),
|
||||||
|
* @OA\Response(response=403, description="Invalid CSRF token")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,84 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/upload/upload.php
|
// public/api/upload/upload.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/upload/upload.php",
|
||||||
|
* summary="Upload a file (supports chunked + full uploads)",
|
||||||
|
* description="Requires a session (cookie) and a CSRF token (header preferred; falls back to form field). Checks user/account flags and folder-level WRITE ACL, then delegates to the model. Returns JSON for chunked uploads; full uploads may redirect after success.",
|
||||||
|
* operationId="handleUpload",
|
||||||
|
* tags={"Uploads"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="X-CSRF-Token", in="header", required=false,
|
||||||
|
* description="CSRF token for this session (preferred). If omitted, send as form field `csrf_token`.",
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
* content={
|
||||||
|
* "multipart/form-data": @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
* @OA\Schema(
|
||||||
|
* type="object",
|
||||||
|
* required={"fileToUpload"},
|
||||||
|
* @OA\Property(
|
||||||
|
* property="fileToUpload", type="string", format="binary",
|
||||||
|
* description="File or chunk payload."
|
||||||
|
* ),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="folder", type="string", example="root",
|
||||||
|
* description="Target folder (defaults to 'root' if omitted)."
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="csrf_token", type="string", description="CSRF token (form fallback)."),
|
||||||
|
* @OA\Property(property="upload_token", type="string", description="Legacy alias for CSRF token (accepted by server)."),
|
||||||
|
* @OA\Property(property="resumableChunkNumber", type="integer"),
|
||||||
|
* @OA\Property(property="resumableTotalChunks", type="integer"),
|
||||||
|
* @OA\Property(property="resumableChunkSize", type="integer"),
|
||||||
|
* @OA\Property(property="resumableCurrentChunkSize", type="integer"),
|
||||||
|
* @OA\Property(property="resumableTotalSize", type="integer"),
|
||||||
|
* @OA\Property(property="resumableType", type="string"),
|
||||||
|
* @OA\Property(property="resumableIdentifier", type="string"),
|
||||||
|
* @OA\Property(property="resumableFilename", type="string"),
|
||||||
|
* @OA\Property(property="resumableRelativePath", type="string")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="JSON result (success, chunk status, or CSRF refresh).",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* oneOf={
|
||||||
|
* @OA\Schema( ; Success (full or model-returned)
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="success", type="string", example="File uploaded successfully"),
|
||||||
|
* @OA\Property(property="newFilename", type="string", example="5f2d7c123a_example.png")
|
||||||
|
* ),
|
||||||
|
* @OA\Schema( ; Chunk flow
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="status", type="string", example="chunk uploaded")
|
||||||
|
* ),
|
||||||
|
* @OA\Schema( ; CSRF soft-refresh path
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="csrf_expired", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="csrf_token", type="string", example="b1c2...f9")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=302,
|
||||||
|
* description="Redirect after a successful full upload.",
|
||||||
|
* @OA\Header(header="Location", description="Where the client is redirected", @OA\Schema(type="string"))
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="Bad request (missing/invalid fields, model error)"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized (no session)"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden (upload disabled or no WRITE to folder)"),
|
||||||
|
* @OA\Response(response=500, description="Server error while processing upload")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
|
require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
|
||||||
|
|
||||||
|
|||||||
@@ -2305,4 +2305,7 @@ body.dark-mode .user-dropdown .user-menu .item:hover {
|
|||||||
.folder-strip-container .folder-item:hover {
|
.folder-strip-container .folder-item:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root { --perm-caret: #444; } /* light */
|
||||||
|
body.dark-mode { --perm-caret: #ccc; } /* dark */
|
||||||
@@ -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="">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
|
const lo = config.loginOptions || {};
|
||||||
localStorage.setItem("authBypass", String(!!config.loginOptions.authBypass));
|
localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin));
|
||||||
localStorage.setItem("authHeaderName", config.loginOptions.authHeaderName || "X-Remote-User");
|
localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth));
|
||||||
|
localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin));
|
||||||
|
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
|
||||||
|
// These may be absent for non-admins; default them
|
||||||
|
localStorage.setItem("authBypass", String(!!lo.authBypass));
|
||||||
|
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,32 +5,74 @@
|
|||||||
// It also includes functions to handle the drag-and-drop events, including mouse movements and drop zones.
|
// It also includes functions to handle the drag-and-drop events, including mouse movements and drop zones.
|
||||||
// It uses CSS classes to manage the appearance of the sidebar and header drop zones during drag-and-drop operations.
|
// It uses CSS classes to manage the appearance of the sidebar and header drop zones during drag-and-drop operations.
|
||||||
|
|
||||||
|
// ---- responsive defaults ----
|
||||||
|
const MEDIUM_MIN = 1205; // matches your small-screen cutoff
|
||||||
|
const MEDIUM_MAX = 1600; // tweak as you like
|
||||||
|
|
||||||
|
function isMediumScreen() {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
return w >= MEDIUM_MIN && w < MEDIUM_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
// Moves cards into the sidebar based on the saved order in localStorage.
|
// Moves cards into the sidebar based on the saved order in localStorage.
|
||||||
export function loadSidebarOrder() {
|
export function loadSidebarOrder() {
|
||||||
const sidebar = document.getElementById('sidebarDropArea');
|
const sidebar = document.getElementById('sidebarDropArea');
|
||||||
if (!sidebar) return;
|
if (!sidebar) return;
|
||||||
const orderStr = localStorage.getItem('sidebarOrder');
|
|
||||||
if (orderStr) {
|
const orderStr = localStorage.getItem('sidebarOrder');
|
||||||
const order = JSON.parse(orderStr);
|
const headerOrderStr = localStorage.getItem('headerOrder');
|
||||||
if (order.length > 0) {
|
const defaultAppliedKey = 'layoutDefaultApplied_v1'; // bump the suffix if you ever change logic
|
||||||
// Ensure main wrapper is visible.
|
|
||||||
const mainWrapper = document.querySelector('.main-wrapper');
|
// If we have a saved order (sidebar or header), just honor it as before
|
||||||
if (mainWrapper) {
|
if (orderStr) {
|
||||||
mainWrapper.style.display = 'flex';
|
const order = JSON.parse(orderStr || '[]');
|
||||||
|
if (Array.isArray(order) && order.length > 0) {
|
||||||
|
const mainWrapper = document.querySelector('.main-wrapper');
|
||||||
|
if (mainWrapper) mainWrapper.style.display = 'flex';
|
||||||
|
order.forEach(id => {
|
||||||
|
const card = document.getElementById(id);
|
||||||
|
if (card && card.parentNode?.id !== 'sidebarDropArea') {
|
||||||
|
sidebar.appendChild(card);
|
||||||
|
animateVerticalSlide(card);
|
||||||
}
|
}
|
||||||
// For each saved ID, move the card into the sidebar.
|
});
|
||||||
order.forEach(id => {
|
updateSidebarVisibility();
|
||||||
const card = document.getElementById(id);
|
return;
|
||||||
if (card && card.parentNode.id !== 'sidebarDropArea') {
|
|
||||||
sidebar.appendChild(card);
|
|
||||||
// Animate vertical slide for sidebar card
|
|
||||||
animateVerticalSlide(card);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
updateSidebarVisibility();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No sidebar order saved yet: if user has header icons saved, do nothing (they've customized)
|
||||||
|
const headerOrder = JSON.parse(headerOrderStr || '[]');
|
||||||
|
if (Array.isArray(headerOrder) && headerOrder.length > 0) {
|
||||||
|
updateSidebarVisibility();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time default: on medium screens, start cards in the sidebar
|
||||||
|
const alreadyApplied = localStorage.getItem(defaultAppliedKey) === '1';
|
||||||
|
if (!alreadyApplied && isMediumScreen()) {
|
||||||
|
const mainWrapper = document.querySelector('.main-wrapper');
|
||||||
|
if (mainWrapper) mainWrapper.style.display = 'flex';
|
||||||
|
|
||||||
|
const candidates = ['uploadCard', 'folderManagementCard'];
|
||||||
|
const moved = [];
|
||||||
|
candidates.forEach(id => {
|
||||||
|
const card = document.getElementById(id);
|
||||||
|
if (card && card.parentNode?.id !== 'sidebarDropArea') {
|
||||||
|
sidebar.appendChild(card);
|
||||||
|
animateVerticalSlide(card);
|
||||||
|
moved.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (moved.length) {
|
||||||
|
localStorage.setItem('sidebarOrder', JSON.stringify(moved));
|
||||||
|
localStorage.setItem(defaultAppliedKey, '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSidebarVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function loadHeaderOrder() {
|
export function loadHeaderOrder() {
|
||||||
|
|||||||
@@ -9,29 +9,62 @@ const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
|
|||||||
|
|
||||||
// Lazy-load CodeMirror modes on demand
|
// Lazy-load CodeMirror modes on demand
|
||||||
const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
|
const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
|
||||||
|
|
||||||
|
// Which mode file to load for a given name/mime
|
||||||
const MODE_URL = {
|
const MODE_URL = {
|
||||||
// core you've likely already loaded:
|
// core/common
|
||||||
"xml": "mode/xml/xml.min.js",
|
"xml": "mode/xml/xml.min.js",
|
||||||
"css": "mode/css/css.min.js",
|
"css": "mode/css/css.min.js",
|
||||||
"javascript": "mode/javascript/javascript.min.js",
|
"javascript": "mode/javascript/javascript.min.js",
|
||||||
|
|
||||||
// extras you may want on-demand:
|
// meta / combos
|
||||||
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js",
|
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js",
|
||||||
"application/x-httpd-php": "mode/php/php.min.js",
|
"application/x-httpd-php": "mode/php/php.min.js",
|
||||||
"php": "mode/php/php.min.js",
|
|
||||||
"markdown": "mode/markdown/markdown.min.js",
|
// docs / data
|
||||||
"python": "mode/python/python.min.js",
|
"markdown": "mode/markdown/markdown.min.js",
|
||||||
"sql": "mode/sql/sql.min.js",
|
"yaml": "mode/yaml/yaml.min.js",
|
||||||
"shell": "mode/shell/shell.min.js",
|
|
||||||
"yaml": "mode/yaml/yaml.min.js",
|
|
||||||
"properties": "mode/properties/properties.min.js",
|
"properties": "mode/properties/properties.min.js",
|
||||||
"text/x-csrc": "mode/clike/clike.min.js",
|
"sql": "mode/sql/sql.min.js",
|
||||||
"text/x-c++src": "mode/clike/clike.min.js",
|
|
||||||
"text/x-java": "mode/clike/clike.min.js",
|
// shells
|
||||||
"text/x-csharp": "mode/clike/clike.min.js",
|
"shell": "mode/shell/shell.min.js",
|
||||||
"text/x-kotlin": "mode/clike/clike.min.js"
|
|
||||||
|
// languages
|
||||||
|
"python": "mode/python/python.min.js",
|
||||||
|
"text/x-csrc": "mode/clike/clike.min.js",
|
||||||
|
"text/x-c++src": "mode/clike/clike.min.js",
|
||||||
|
"text/x-java": "mode/clike/clike.min.js",
|
||||||
|
"text/x-csharp": "mode/clike/clike.min.js",
|
||||||
|
"text/x-kotlin": "mode/clike/clike.min.js"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Map any mime/alias to the key we use in MODE_URL
|
||||||
|
function normalizeModeName(modeOption) {
|
||||||
|
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
|
||||||
|
if (!name) return null;
|
||||||
|
if (name === "text/html") return "htmlmixed"; // CodeMirror uses htmlmixed for HTML
|
||||||
|
if (name === "php") return "application/x-httpd-php"; // prefer the full mime
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_SRI = {
|
||||||
|
"mode/xml/xml.min.js": "sha512-LarNmzVokUmcA7aUDtqZ6oTS+YXmUKzpGdm8DxC46A6AHu+PQiYCUlwEGWidjVYMo/QXZMFMIadZtrkfApYp/g==",
|
||||||
|
"mode/css/css.min.js": "sha512-oikhYLgIKf0zWtVTOXh101BWoSacgv4UTJHQOHU+iUQ1Dol3Xjz/o9Jh0U33MPoT/d4aQruvjNvcYxvkTQd0nA==",
|
||||||
|
"mode/javascript/javascript.min.js": "sha512-I6CdJdruzGtvDyvdO4YsiAq+pkWf2efgd1ZUSK2FnM/u2VuRASPC7GowWQrWyjxCZn6CT89s3ddGI+be0Ak9Fg==",
|
||||||
|
"mode/htmlmixed/htmlmixed.min.js": "sha512-HN6cn6mIWeFJFwRN9yetDAMSh+AK9myHF1X9GlSlKmThaat65342Yw8wL7ITuaJnPioG0SYG09gy0qd5+s777w==",
|
||||||
|
"mode/php/php.min.js": "sha512-jZGz5n9AVTuQGhKTL0QzOm6bxxIQjaSbins+vD3OIdI7mtnmYE6h/L+UBGIp/SssLggbkxRzp9XkQNA4AyjFBw==",
|
||||||
|
"mode/markdown/markdown.min.js": "sha512-DmMao0nRIbyDjbaHc8fNd3kxGsZj9PCU6Iu/CeidLQT9Py8nYVA5n0PqXYmvqNdU+lCiTHOM/4E7bM/G8BttJg==",
|
||||||
|
"mode/python/python.min.js": "sha512-2M0GdbU5OxkGYMhakED69bw0c1pW3Nb0PeF3+9d+SnwN1ryPx3wiDdNqK3gSM7KAU/pEV+2tFJFbMKjKAahOkQ==",
|
||||||
|
"mode/sql/sql.min.js": "sha512-u8r8NUnG9B9L2dDmsfvs9ohQ0SO/Z7MB8bkdLxV7fE0Q8bOeP7/qft1D4KyE8HhVrpH3ihSrRoDiMbYR1VQBWQ==",
|
||||||
|
"mode/shell/shell.min.js": "sha512-HoC6JXgjHHevWAYqww37Gfu2c1G7SxAOv42wOakjR8csbTUfTB7OhVzSJ95LL62nII0RCyImp+7nR9zGmJ1wRQ==",
|
||||||
|
"mode/yaml/yaml.min.js": "sha512-+aXDZ93WyextRiAZpsRuJyiAZ38ztttUyO/H3FZx4gOAOv4/k9C6Um1CvHVtaowHZ2h7kH0d+orWvdBLPVwb4g==",
|
||||||
|
"mode/properties/properties.min.js": "sha512-P4OaO+QWj1wPRsdkEHlrgkx+a7qp6nUC8rI6dS/0/HPjHtlEmYfiambxowYa/UfqTxyNUnwTyPt5U6l1GO76yw==",
|
||||||
|
"mode/clike/clike.min.js": "sha512-l8ZIWnQ3XHPRG3MQ8+hT1OffRSTrFwrph1j1oc1Fzc9UKVGef5XN9fdO0vm3nW0PRgQ9LJgck6ciG59m69rvfg=="
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
|
||||||
|
|
||||||
function loadScriptOnce(url) {
|
function loadScriptOnce(url) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const key = `cm:${url}`;
|
const key = `cm:${url}`;
|
||||||
@@ -39,30 +72,47 @@ function loadScriptOnce(url) {
|
|||||||
if (s) {
|
if (s) {
|
||||||
if (s.dataset.loaded === "1") return resolve();
|
if (s.dataset.loaded === "1") return resolve();
|
||||||
s.addEventListener("load", () => resolve());
|
s.addEventListener("load", () => resolve());
|
||||||
s.addEventListener("error", reject);
|
s.addEventListener("error", () => reject(new Error(`Load failed: ${url}`)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
s = document.createElement("script");
|
s = document.createElement("script");
|
||||||
s.src = url;
|
s.src = url;
|
||||||
s.defer = true;
|
s.async = true;
|
||||||
s.dataset.key = key;
|
s.dataset.key = key;
|
||||||
|
|
||||||
|
// 🔒 Add SRI if we have it
|
||||||
|
const relPath = url.replace(/^https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/codemirror\/5\.65\.5\//, "");
|
||||||
|
const sri = MODE_SRI[relPath];
|
||||||
|
if (sri) {
|
||||||
|
s.integrity = sri;
|
||||||
|
s.crossOrigin = "anonymous";
|
||||||
|
// (Optional) further tighten referrer behavior:
|
||||||
|
// s.referrerPolicy = "no-referrer";
|
||||||
|
}
|
||||||
|
|
||||||
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
|
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
|
||||||
s.addEventListener("error", reject);
|
s.addEventListener("error", () => reject(new Error(`Load failed: ${url}`)));
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function ensureModeLoaded(modeOption) {
|
async function ensureModeLoaded(modeOption) {
|
||||||
if (!window.CodeMirror) return; // CM core must be present
|
if (!window.CodeMirror) return;
|
||||||
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
|
|
||||||
|
const name = normalizeModeName(modeOption);
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
// Already registered?
|
|
||||||
if ((CodeMirror.modes && CodeMirror.modes[name]) || (CodeMirror.mimeModes && CodeMirror.mimeModes[name])) {
|
const isRegistered = () =>
|
||||||
return;
|
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) ||
|
||||||
}
|
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name]);
|
||||||
|
|
||||||
|
if (isRegistered()) return;
|
||||||
|
|
||||||
const url = MODE_URL[name];
|
const url = MODE_URL[name];
|
||||||
if (!url) return; // unknown -> fallback to text/plain
|
if (!url) return; // unknown -> stay in text/plain
|
||||||
// Dependencies (htmlmixed needs xml/css/js; php highlighting with HTML also benefits from htmlmixed)
|
|
||||||
|
// Dependencies
|
||||||
if (name === "htmlmixed") {
|
if (name === "htmlmixed") {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
ensureModeLoaded("xml"),
|
ensureModeLoaded("xml"),
|
||||||
@@ -73,6 +123,7 @@ async function ensureModeLoaded(modeOption) {
|
|||||||
if (name === "application/x-httpd-php") {
|
if (name === "application/x-httpd-php") {
|
||||||
await ensureModeLoaded("htmlmixed");
|
await ensureModeLoaded("htmlmixed");
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadScriptOnce(CM_CDN + url);
|
await loadScriptOnce(CM_CDN + url);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,67 +132,39 @@ function getModeForFile(fileName) {
|
|||||||
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
||||||
|
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
// markup
|
|
||||||
case "html":
|
case "html":
|
||||||
case "htm":
|
case "htm": return "text/html";
|
||||||
return "text/html"; // ensureModeLoaded will map to htmlmixed
|
case "xml": return "xml";
|
||||||
case "xml":
|
|
||||||
return "xml";
|
|
||||||
case "md":
|
case "md":
|
||||||
case "markdown":
|
case "markdown": return "markdown";
|
||||||
return "markdown";
|
|
||||||
case "yml":
|
case "yml":
|
||||||
case "yaml":
|
case "yaml": return "yaml";
|
||||||
return "yaml";
|
case "css": return "css";
|
||||||
|
case "js": return "javascript";
|
||||||
// styles & scripts
|
case "json": return { name: "javascript", json: true };
|
||||||
case "css":
|
case "php": return "application/x-httpd-php";
|
||||||
return "css";
|
case "py": return "python";
|
||||||
case "js":
|
case "sql": return "sql";
|
||||||
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 "sh":
|
||||||
case "bash":
|
case "bash":
|
||||||
case "zsh":
|
case "zsh":
|
||||||
case "bat":
|
case "bat": return "shell";
|
||||||
return "shell";
|
|
||||||
|
|
||||||
// config-y files
|
|
||||||
case "ini":
|
case "ini":
|
||||||
case "conf":
|
case "conf":
|
||||||
case "config":
|
case "config":
|
||||||
case "properties":
|
case "properties": return "properties";
|
||||||
return "properties";
|
|
||||||
|
|
||||||
// C-family / JVM
|
|
||||||
case "c":
|
case "c":
|
||||||
case "h":
|
case "h": return "text/x-csrc";
|
||||||
return "text/x-csrc";
|
|
||||||
case "cpp":
|
case "cpp":
|
||||||
case "cxx":
|
case "cxx":
|
||||||
case "hpp":
|
case "hpp":
|
||||||
case "hh":
|
case "hh":
|
||||||
case "hxx":
|
case "hxx": return "text/x-c++src";
|
||||||
return "text/x-c++src";
|
case "java": return "text/x-java";
|
||||||
case "java":
|
case "cs": return "text/x-csharp";
|
||||||
return "text/x-java";
|
|
||||||
case "cs":
|
|
||||||
return "text/x-csharp";
|
|
||||||
case "kt":
|
case "kt":
|
||||||
case "kts":
|
case "kts": return "text/x-kotlin";
|
||||||
return "text/x-kotlin";
|
default: return "text/plain";
|
||||||
|
|
||||||
default:
|
|
||||||
return "text/plain";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export { getModeForFile };
|
export { getModeForFile };
|
||||||
@@ -158,18 +181,15 @@ export { adjustEditorSize };
|
|||||||
|
|
||||||
function observeModalResize(modal) {
|
function observeModalResize(modal) {
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => adjustEditorSize());
|
||||||
adjustEditorSize();
|
|
||||||
});
|
|
||||||
resizeObserver.observe(modal);
|
resizeObserver.observe(modal);
|
||||||
}
|
}
|
||||||
export { observeModalResize };
|
export { observeModalResize };
|
||||||
|
|
||||||
export function editFile(fileName, folder) {
|
export function editFile(fileName, folder) {
|
||||||
|
// destroy any previous editor
|
||||||
let existingEditor = document.getElementById("editorContainer");
|
let existingEditor = document.getElementById("editorContainer");
|
||||||
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"
|
||||||
@@ -179,9 +199,7 @@ export function editFile(fileName, folder) {
|
|||||||
|
|
||||||
fetch(fileUrl, { method: "HEAD" })
|
fetch(fileUrl, { method: "HEAD" })
|
||||||
.then(response => {
|
.then(response => {
|
||||||
const lenHeader =
|
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
|
||||||
response.headers.get("content-length") ??
|
|
||||||
response.headers.get("Content-Length");
|
|
||||||
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||||
|
|
||||||
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
|
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
|
||||||
@@ -192,104 +210,143 @@ export function editFile(fileName, folder) {
|
|||||||
})
|
})
|
||||||
.then(() => fetch(fileUrl))
|
.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);
|
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
|
||||||
}
|
|
||||||
const lenHeader =
|
|
||||||
response.headers.get("content-length") ??
|
|
||||||
response.headers.get("Content-Length");
|
|
||||||
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||||
return Promise.all([response.text(), sizeBytes]);
|
return Promise.all([response.text(), sizeBytes]);
|
||||||
})
|
})
|
||||||
.then(([content, sizeBytes]) => {
|
.then(([content, sizeBytes]) => {
|
||||||
const forcePlainText =
|
const forcePlainText = sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
|
||||||
sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
|
|
||||||
|
|
||||||
|
// --- Build modal immediately and wire close controls BEFORE any async loads ---
|
||||||
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.setAttribute("tabindex", "-1"); // for Escape handling
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="editor-header">
|
<div class="editor-header">
|
||||||
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}${
|
<h3 class="editor-title">
|
||||||
forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""
|
${t("editing")}: ${escapeHTML(fileName)}
|
||||||
}</h3>
|
${forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""}
|
||||||
<div class="editor-controls">
|
</h3>
|
||||||
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
|
<div class="editor-controls">
|
||||||
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_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>
|
||||||
|
</div>
|
||||||
|
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close")}">×</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="closeEditorX" class="editor-close-btn">×</button>
|
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
|
||||||
</div>
|
<div class="editor-footer">
|
||||||
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
|
<button id="saveBtn" class="btn btn-primary" disabled>${t("save")}</button>
|
||||||
<div class="editor-footer">
|
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
|
||||||
<button id="saveBtn" class="btn btn-primary">${t("save")}</button>
|
</div>
|
||||||
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
|
`;
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
modal.style.display = "block";
|
modal.style.display = "block";
|
||||||
|
modal.focus();
|
||||||
|
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
let canceled = false;
|
||||||
const theme = isDarkMode ? "material-darker" : "default";
|
const doClose = () => {
|
||||||
|
canceled = true;
|
||||||
// choose mode + lighter settings for large files
|
window.currentEditor = null;
|
||||||
const mode = forcePlainText ? "text/plain" : getModeForFile(fileName);
|
modal.remove();
|
||||||
const cmOptions = {
|
|
||||||
lineNumbers: !forcePlainText,
|
|
||||||
mode: mode,
|
|
||||||
theme: theme,
|
|
||||||
viewportMargin: forcePlainText ? 20 : Infinity,
|
|
||||||
lineWrapping: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ LOAD MODE FIRST, THEN INSTANTIATE CODEMIRROR
|
// Wire close actions right away
|
||||||
ensureModeLoaded(mode).finally(() => {
|
modal.addEventListener("keydown", (e) => { if (e.key === "Escape") doClose(); });
|
||||||
const editor = CodeMirror.fromTextArea(
|
document.getElementById("closeEditorX").addEventListener("click", doClose);
|
||||||
|
document.getElementById("closeBtn").addEventListener("click", doClose);
|
||||||
|
|
||||||
|
// Keep buttons responsive even before editor exists
|
||||||
|
const decBtn = document.getElementById("decreaseFont");
|
||||||
|
const incBtn = document.getElementById("increaseFont");
|
||||||
|
decBtn.addEventListener("click", () => {});
|
||||||
|
incBtn.addEventListener("click", () => {});
|
||||||
|
|
||||||
|
// Theme + mode selection
|
||||||
|
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||||
|
const theme = isDarkMode ? "material-darker" : "default";
|
||||||
|
const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName);
|
||||||
|
|
||||||
|
// Helper to check whether a mode is currently registered
|
||||||
|
const modeName = typeof desiredMode === "string" ? desiredMode : (desiredMode && desiredMode.name);
|
||||||
|
const isModeRegistered = () =>
|
||||||
|
(window.CodeMirror?.modes && window.CodeMirror.modes[modeName]) ||
|
||||||
|
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[modeName]);
|
||||||
|
|
||||||
|
// Start mode loading (don’t block closing)
|
||||||
|
const modePromise = ensureModeLoaded(desiredMode);
|
||||||
|
|
||||||
|
// Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available
|
||||||
|
const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS));
|
||||||
|
|
||||||
|
Promise.race([modePromise, timeout]).then(() => {
|
||||||
|
if (canceled) return;
|
||||||
|
if (!window.CodeMirror) {
|
||||||
|
// Core not present: keep plain <textarea>; enable Save and bail gracefully
|
||||||
|
document.getElementById("saveBtn").disabled = false;
|
||||||
|
observeModalResize(modal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialMode = (forcePlainText || !isModeRegistered()) ? "text/plain" : desiredMode;
|
||||||
|
const cmOptions = {
|
||||||
|
lineNumbers: !forcePlainText,
|
||||||
|
mode: initialMode,
|
||||||
|
theme,
|
||||||
|
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||||
|
lineWrapping: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const editor = window.CodeMirror.fromTextArea(
|
||||||
document.getElementById("fileEditor"),
|
document.getElementById("fileEditor"),
|
||||||
cmOptions
|
cmOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
window.currentEditor = editor;
|
window.currentEditor = editor;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(adjustEditorSize, 50);
|
||||||
adjustEditorSize();
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
observeModalResize(modal);
|
observeModalResize(modal);
|
||||||
|
|
||||||
|
// Font controls (now that editor exists)
|
||||||
let currentFontSize = 14;
|
let currentFontSize = 14;
|
||||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
const wrapper = editor.getWrapperElement();
|
||||||
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
editor.refresh();
|
||||||
|
|
||||||
document.getElementById("closeEditorX").addEventListener("click", function () {
|
decBtn.addEventListener("click", function () {
|
||||||
modal.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("decreaseFont").addEventListener("click", function () {
|
|
||||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
editor.refresh();
|
||||||
});
|
});
|
||||||
|
incBtn.addEventListener("click", function () {
|
||||||
document.getElementById("increaseFont").addEventListener("click", function () {
|
|
||||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
editor.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("saveBtn").addEventListener("click", function () {
|
// Save
|
||||||
|
const saveBtn = document.getElementById("saveBtn");
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.addEventListener("click", function () {
|
||||||
saveFile(fileName, folderUsed);
|
saveFile(fileName, folderUsed);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("closeBtn").addEventListener("click", function () {
|
// Theme switch
|
||||||
modal.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateEditorTheme() {
|
function updateEditorTheme() {
|
||||||
const isDark = document.body.classList.contains("dark-mode");
|
const isDark = document.body.classList.contains("dark-mode");
|
||||||
editor.setOption("theme", isDark ? "material-darker" : "default");
|
editor.setOption("theme", isDark ? "material-darker" : "default");
|
||||||
}
|
}
|
||||||
const toggle = document.getElementById("darkModeToggle");
|
const toggle = document.getElementById("darkModeToggle");
|
||||||
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
||||||
|
|
||||||
|
// If we started in plain text due to timeout, flip to the real mode once it arrives
|
||||||
|
modePromise.then(() => {
|
||||||
|
if (!canceled && !forcePlainText && isModeRegistered()) {
|
||||||
|
editor.setOption("mode", desiredMode);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// If the mode truly fails to load, we just stay in plain text
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -298,7 +355,6 @@ export function editFile(fileName, folder) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function saveFile(fileName, folder) {
|
export function saveFile(fileName, folder) {
|
||||||
const editor = window.currentEditor;
|
const editor = window.currentEditor;
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
function setControlEnabled(el, enabled) {
|
||||||
if (!normalizedFolder || normalizedFolder === "") return "";
|
if (!el) return;
|
||||||
const parts = normalizedFolder.split("/");
|
if ('disabled' in el) el.disabled = !enabled;
|
||||||
let breadcrumbItems = [];
|
el.classList.toggle('disabled', !enabled);
|
||||||
// Use the first segment as the root.
|
el.setAttribute('aria-disabled', String(!enabled));
|
||||||
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${parts[0]}">${escapeHTML(parts[0])}</span>`);
|
el.style.pointerEvents = enabled ? '' : 'none';
|
||||||
let cumulative = parts[0];
|
el.style.opacity = enabled ? '' : '0.5';
|
||||||
parts.slice(1).forEach(part => {
|
|
||||||
cumulative += "/" + part;
|
|
||||||
breadcrumbItems.push(`<span class="breadcrumb-separator"> / </span>`);
|
|
||||||
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${cumulative}">${escapeHTML(part)}</span>`);
|
|
||||||
});
|
|
||||||
return breadcrumbItems.join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NEW: Breadcrumb Delegation Setup ---
|
async function applyFolderCapabilities(folder) {
|
||||||
// bindBreadcrumbEvents(); removed in favor of delegation
|
const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const caps = await res.json();
|
||||||
|
window.currentFolderCaps = caps;
|
||||||
|
|
||||||
|
const isRoot = (folder === 'root');
|
||||||
|
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
|
||||||
|
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
|
||||||
|
setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete);
|
||||||
|
setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Breadcrumb Delegation Setup ---
|
||||||
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,14 +140,13 @@ 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");
|
||||||
|
applyFolderCapabilities(window.currentFolder);
|
||||||
|
|
||||||
loadFileList(folder);
|
loadFileList(folder);
|
||||||
}
|
}
|
||||||
@@ -158,20 +182,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 +208,39 @@ function breadcrumbDropHandler(e) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ----------------------
|
/* ----------------------
|
||||||
Check Current User's Folder-Only Permission
|
Check Current User's Folder-Only Permission
|
||||||
----------------------*/
|
----------------------*/
|
||||||
// This function uses localStorage values (set during login) to determine if the current user is restricted.
|
// Authoritatively determine from the server; still write to localStorage for UI,
|
||||||
// If folderOnly is "true", then the personal folder (i.e. username) is forced as the effective root.
|
// but ignore any preexisting localStorage override for security.
|
||||||
function checkUserFolderPermission() {
|
async function checkUserFolderPermission() {
|
||||||
const username = localStorage.getItem("username");
|
const username = localStorage.getItem("username") || "";
|
||||||
console.log("checkUserFolderPermission: username =", username);
|
try {
|
||||||
if (!username) {
|
const res = await fetchWithCsrf("/api/getUserPermissions.php", {
|
||||||
console.warn("No username in localStorage; skipping getUserPermissions fetch.");
|
method: "GET",
|
||||||
return Promise.resolve(false);
|
credentials: "include"
|
||||||
}
|
|
||||||
if (localStorage.getItem("folderOnly") === "true") {
|
|
||||||
window.userFolderOnly = true;
|
|
||||||
console.log("checkUserFolderPermission: using localStorage.folderOnly = true");
|
|
||||||
localStorage.setItem("lastOpenedFolder", username);
|
|
||||||
window.currentFolder = username;
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
return fetch("/api/getUserPermissions.php", { credentials: "include" })
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(permissionsData => {
|
|
||||||
console.log("checkUserFolderPermission: permissionsData =", permissionsData);
|
|
||||||
if (permissionsData && permissionsData[username] && permissionsData[username].folderOnly) {
|
|
||||||
window.userFolderOnly = true;
|
|
||||||
localStorage.setItem("folderOnly", "true");
|
|
||||||
localStorage.setItem("lastOpenedFolder", username);
|
|
||||||
window.currentFolder = username;
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
window.userFolderOnly = false;
|
|
||||||
localStorage.setItem("folderOnly", "false");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error("Error fetching user permissions:", err);
|
|
||||||
window.userFolderOnly = false;
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
|
const permissionsData = await safeJson(res);
|
||||||
|
|
||||||
|
const isFolderOnly =
|
||||||
|
!!(permissionsData &&
|
||||||
|
permissionsData[username] &&
|
||||||
|
permissionsData[username].folderOnly);
|
||||||
|
|
||||||
|
window.userFolderOnly = isFolderOnly;
|
||||||
|
localStorage.setItem("folderOnly", isFolderOnly ? "true" : "false");
|
||||||
|
|
||||||
|
if (isFolderOnly && username) {
|
||||||
|
localStorage.setItem("lastOpenedFolder", username);
|
||||||
|
window.currentFolder = username;
|
||||||
|
}
|
||||||
|
return isFolderOnly;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching user permissions:", err);
|
||||||
|
window.userFolderOnly = false;
|
||||||
|
localStorage.setItem("folderOnly", "false");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------
|
/* ----------------------
|
||||||
@@ -273,7 +287,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 +321,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 +350,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 +375,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 +428,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 +469,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 +495,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 +508,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 +531,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,10 +551,12 @@ export async function loadFolderTree(selectedFolder) {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading folder tree:", error);
|
console.error("Error loading folder tree:", error);
|
||||||
|
if (error.status === 403) {
|
||||||
|
showToast("You don't have permission to view folders.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// For backward compatibility.
|
// For backward compatibility.
|
||||||
export function loadFolderList(selectedFolder) {
|
export function loadFolderList(selectedFolder) {
|
||||||
loadFolderTree(selectedFolder);
|
loadFolderTree(selectedFolder);
|
||||||
@@ -548,8 +565,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,61 +578,69 @@ export function openRenameFolderModal() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parts = selectedFolder.split("/");
|
const parts = selectedFolder.split("/");
|
||||||
document.getElementById("newRenameFolderName").value = parts[parts.length - 1];
|
const input = document.getElementById("newRenameFolderName");
|
||||||
document.getElementById("renameFolderModal").style.display = "block";
|
const modal = document.getElementById("renameFolderModal");
|
||||||
|
if (!input || !modal) return;
|
||||||
|
input.value = parts[parts.length - 1];
|
||||||
|
modal.style.display = "block";
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const input = document.getElementById("newRenameFolderName");
|
|
||||||
input.focus();
|
input.focus();
|
||||||
input.select();
|
input.select();
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("cancelRenameFolder").addEventListener("click", function () {
|
const cancelRename = document.getElementById("cancelRenameFolder");
|
||||||
document.getElementById("renameFolderModal").style.display = "none";
|
if (cancelRename) {
|
||||||
document.getElementById("newRenameFolderName").value = "";
|
cancelRename.addEventListener("click", function () {
|
||||||
});
|
const modal = document.getElementById("renameFolderModal");
|
||||||
|
const input = document.getElementById("newRenameFolderName");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
if (input) input.value = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
attachEnterKeyListener("renameFolderModal", "submitRenameFolder");
|
attachEnterKeyListener("renameFolderModal", "submitRenameFolder");
|
||||||
document.getElementById("submitRenameFolder").addEventListener("click", function (event) {
|
|
||||||
event.preventDefault();
|
const submitRename = document.getElementById("submitRenameFolder");
|
||||||
const selectedFolder = window.currentFolder || "root";
|
if (submitRename) {
|
||||||
const newNameBasename = document.getElementById("newRenameFolderName").value.trim();
|
submitRename.addEventListener("click", function (event) {
|
||||||
if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) {
|
event.preventDefault();
|
||||||
showToast("Please enter a valid new folder name.");
|
const selectedFolder = window.currentFolder || "root";
|
||||||
return;
|
const input = document.getElementById("newRenameFolderName");
|
||||||
}
|
if (!input) return;
|
||||||
const parentPath = getParentFolder(selectedFolder);
|
const newNameBasename = input.value.trim();
|
||||||
const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename;
|
if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) {
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
showToast("Please enter a valid new folder name.");
|
||||||
if (!csrfToken) {
|
return;
|
||||||
showToast("CSRF token not loaded yet! Please try again.");
|
}
|
||||||
return;
|
const parentPath = getParentFolder(selectedFolder);
|
||||||
}
|
const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename;
|
||||||
fetch("/api/folder/renameFolder.php", {
|
|
||||||
method: "POST",
|
fetchWithCsrf("/api/folder/renameFolder.php", {
|
||||||
credentials: "include",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
credentials: "include",
|
||||||
"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(data => {
|
|
||||||
if (data.success) {
|
|
||||||
showToast("Folder renamed successfully!");
|
|
||||||
window.currentFolder = newFolderFull;
|
|
||||||
localStorage.setItem("lastOpenedFolder", newFolderFull);
|
|
||||||
loadFolderList(newFolderFull);
|
|
||||||
} else {
|
|
||||||
showToast("Error: " + (data.error || "Could not rename folder"));
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(error => console.error("Error renaming folder:", error))
|
.then(safeJson)
|
||||||
.finally(() => {
|
.then(data => {
|
||||||
document.getElementById("renameFolderModal").style.display = "none";
|
if (data.success) {
|
||||||
document.getElementById("newRenameFolderName").value = "";
|
showToast("Folder renamed successfully!");
|
||||||
});
|
window.currentFolder = newFolderFull;
|
||||||
});
|
localStorage.setItem("lastOpenedFolder", newFolderFull);
|
||||||
|
loadFolderList(newFolderFull);
|
||||||
|
} else {
|
||||||
|
showToast("Error: " + (data.error || "Could not rename folder"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error("Error renaming folder:", error))
|
||||||
|
.finally(() => {
|
||||||
|
const modal = document.getElementById("renameFolderModal");
|
||||||
|
const input2 = document.getElementById("newRenameFolderName");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
if (input2) input2.value = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function openDeleteFolderModal() {
|
export function openDeleteFolderModal() {
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
@@ -620,102 +648,117 @@ 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 selectedFolder = window.currentFolder || "root";
|
const confirmDelete = document.getElementById("confirmDeleteFolder");
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
if (confirmDelete) {
|
||||||
fetch("/api/folder/deleteFolder.php", {
|
confirmDelete.addEventListener("click", function () {
|
||||||
method: "POST",
|
const selectedFolder = window.currentFolder || "root";
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
fetchWithCsrf("/api/folder/deleteFolder.php", {
|
||||||
"X-CSRF-Token": csrfToken
|
method: "POST",
|
||||||
},
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ folder: selectedFolder })
|
credentials: "include",
|
||||||
})
|
body: JSON.stringify({ folder: selectedFolder })
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
showToast("Folder deleted successfully!");
|
|
||||||
window.currentFolder = getParentFolder(selectedFolder);
|
|
||||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
|
||||||
loadFolderList(window.currentFolder);
|
|
||||||
} else {
|
|
||||||
showToast("Error: " + (data.error || "Could not delete folder"));
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(error => console.error("Error deleting folder:", error))
|
.then(safeJson)
|
||||||
.finally(() => {
|
.then(data => {
|
||||||
document.getElementById("deleteFolderModal").style.display = "none";
|
if (data.success) {
|
||||||
});
|
showToast("Folder deleted successfully!");
|
||||||
});
|
window.currentFolder = getParentFolder(selectedFolder);
|
||||||
|
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||||
document.getElementById("createFolderBtn").addEventListener("click", function () {
|
loadFolderList(window.currentFolder);
|
||||||
document.getElementById("createFolderModal").style.display = "block";
|
} else {
|
||||||
document.getElementById("newFolderName").focus();
|
showToast("Error: " + (data.error || "Could not delete folder"));
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("cancelCreateFolder").addEventListener("click", function () {
|
|
||||||
document.getElementById("createFolderModal").style.display = "none";
|
|
||||||
document.getElementById("newFolderName").value = "";
|
|
||||||
});
|
|
||||||
attachEnterKeyListener("createFolderModal", "submitCreateFolder");
|
|
||||||
document.getElementById("submitCreateFolder").addEventListener("click", async () => {
|
|
||||||
const folderInput = document.getElementById("newFolderName").value.trim();
|
|
||||||
if (!folderInput) return showToast("Please enter a folder name.");
|
|
||||||
|
|
||||||
const selectedFolder = window.currentFolder || "root";
|
|
||||||
const parent = selectedFolder === "root" ? "" : selectedFolder;
|
|
||||||
|
|
||||||
// 1) Guarantee fresh CSRF
|
|
||||||
try {
|
|
||||||
await loadCsrfToken();
|
|
||||||
} catch {
|
|
||||||
return showToast("Could not refresh CSRF token. Please reload.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Call with fetchWithCsrf
|
|
||||||
fetchWithCsrf("/api/folder/createFolder.php", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ folderName: folderInput, parent })
|
|
||||||
})
|
|
||||||
.then(async res => {
|
|
||||||
if (!res.ok) {
|
|
||||||
// pull out a JSON error, or fallback to status text
|
|
||||||
let err;
|
|
||||||
try {
|
|
||||||
const j = await res.json();
|
|
||||||
err = j.error || j.message || res.statusText;
|
|
||||||
} catch {
|
|
||||||
err = res.statusText;
|
|
||||||
}
|
}
|
||||||
throw new Error(err);
|
})
|
||||||
}
|
.catch(error => console.error("Error deleting folder:", error))
|
||||||
return res.json();
|
.finally(() => {
|
||||||
|
const modal = document.getElementById("deleteFolderModal");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBtn = document.getElementById("createFolderBtn");
|
||||||
|
if (createBtn) {
|
||||||
|
createBtn.addEventListener("click", function () {
|
||||||
|
const modal = document.getElementById("createFolderModal");
|
||||||
|
const input = document.getElementById("newFolderName");
|
||||||
|
if (modal) modal.style.display = "block";
|
||||||
|
if (input) input.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelCreate = document.getElementById("cancelCreateFolder");
|
||||||
|
if (cancelCreate) {
|
||||||
|
cancelCreate.addEventListener("click", function () {
|
||||||
|
const modal = document.getElementById("createFolderModal");
|
||||||
|
const input = document.getElementById("newFolderName");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
if (input) input.value = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
attachEnterKeyListener("createFolderModal", "submitCreateFolder");
|
||||||
|
|
||||||
|
const submitCreate = document.getElementById("submitCreateFolder");
|
||||||
|
if (submitCreate) {
|
||||||
|
submitCreate.addEventListener("click", async () => {
|
||||||
|
const input = document.getElementById("newFolderName");
|
||||||
|
const folderInput = input ? input.value.trim() : "";
|
||||||
|
if (!folderInput) return showToast("Please enter a folder name.");
|
||||||
|
|
||||||
|
const selectedFolder = window.currentFolder || "root";
|
||||||
|
const parent = selectedFolder === "root" ? "" : selectedFolder;
|
||||||
|
|
||||||
|
// 1) Guarantee fresh CSRF
|
||||||
|
try {
|
||||||
|
await loadCsrfToken();
|
||||||
|
} catch {
|
||||||
|
return showToast("Could not refresh CSRF token. Please reload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Call with fetchWithCsrf
|
||||||
|
fetchWithCsrf("/api/folder/createFolder.php", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ folderName: folderInput, parent })
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(safeJson)
|
||||||
showToast("Folder created!");
|
.then(data => {
|
||||||
const full = parent ? `${parent}/${folderInput}` : folderInput;
|
if (!data.success) throw new Error(data.error || "Server rejected the request");
|
||||||
window.currentFolder = full;
|
showToast("Folder created!");
|
||||||
localStorage.setItem("lastOpenedFolder", full);
|
const full = parent ? `${parent}/${folderInput}` : folderInput;
|
||||||
loadFolderList(full);
|
window.currentFolder = full;
|
||||||
})
|
localStorage.setItem("lastOpenedFolder", full);
|
||||||
.catch(e => {
|
loadFolderList(full);
|
||||||
showToast("Error creating folder: " + e.message);
|
})
|
||||||
})
|
.catch(e => {
|
||||||
.finally(() => {
|
showToast("Error creating folder: " + e.message);
|
||||||
document.getElementById("createFolderModal").style.display = "none";
|
})
|
||||||
document.getElementById("newFolderName").value = "";
|
.finally(() => {
|
||||||
});
|
const modal = document.getElementById("createFolderModal");
|
||||||
});
|
const input2 = document.getElementById("newFolderName");
|
||||||
|
if (modal) modal.style.display = "none";
|
||||||
|
if (input2) input2.value = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
|
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
|
||||||
export function showFolderManagerContextMenu(x, y, menuItems) {
|
export function showFolderManagerContextMenu(x, y, menuItems) {
|
||||||
@@ -773,21 +816,29 @@ 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;
|
||||||
|
applyFolderCapabilities(window.currentFolder);
|
||||||
|
|
||||||
|
// 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 +857,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 +893,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 +915,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 +922,5 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initial context menu delegation bind
|
||||||
bindFolderManagerContextMenu();
|
bindFolderManagerContextMenu();
|
||||||
@@ -51,6 +51,52 @@ async function fetchWithCsrfAndRefresh(input, init = {}) {
|
|||||||
// Replace global fetch with the wrapped version so *all* callers benefit.
|
// Replace global fetch with the wrapped version so *all* callers benefit.
|
||||||
window.fetch = fetchWithCsrfAndRefresh;
|
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
|
APP INIT
|
||||||
========================= */
|
========================= */
|
||||||
@@ -62,7 +108,7 @@ export function initializeApp() {
|
|||||||
window.currentFolder = "root";
|
window.currentFolder = "root";
|
||||||
const stored = localStorage.getItem('showFoldersInList');
|
const stored = localStorage.getItem('showFoldersInList');
|
||||||
window.showFoldersInList = stored === null ? true : stored === 'true';
|
window.showFoldersInList = stored === null ? true : stored === 'true';
|
||||||
|
loadAdminConfigFunc();
|
||||||
initTagSearch();
|
initTagSearch();
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
|
||||||
@@ -93,8 +139,12 @@ export function initializeApp() {
|
|||||||
initFileActions();
|
initFileActions();
|
||||||
initUpload();
|
initUpload();
|
||||||
loadFolderTree();
|
loadFolderTree();
|
||||||
setupTrashRestoreDelete();
|
// Only run trash/restore for admins
|
||||||
loadAdminConfigFunc();
|
const isAdmin =
|
||||||
|
localStorage.getItem('isAdmin') === '1' || localStorage.getItem('isAdmin') === 'true';
|
||||||
|
if (isAdmin) {
|
||||||
|
setupTrashRestoreDelete();
|
||||||
|
}
|
||||||
|
|
||||||
const helpBtn = document.getElementById("folderHelpBtn");
|
const helpBtn = document.getElementById("folderHelpBtn");
|
||||||
const helpTooltip = document.getElementById("folderHelpTooltip");
|
const helpTooltip = document.getElementById("folderHelpTooltip");
|
||||||
@@ -170,6 +220,7 @@ window.openDownloadModal = openDownloadModal;
|
|||||||
window.currentFolder = "root";
|
window.currentFolder = "root";
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Load admin config early
|
||||||
loadAdminConfigFunc();
|
loadAdminConfigFunc();
|
||||||
|
|
||||||
// i18n
|
// i18n
|
||||||
|
|||||||
@@ -13,56 +13,62 @@ if (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── 1) Bootstrap & load models ─────────────────────────────────────────────
|
// ─── 1) Bootstrap & load models ─────────────────────────────────────────────
|
||||||
require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
|
require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, loadUserPermissions(), etc.
|
||||||
require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV
|
require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV
|
||||||
require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole(), loadFolderPermission()
|
require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole()
|
||||||
require_once __DIR__ . '/../src/models/AdminModel.php'; // AdminModel::getConfig()
|
require_once __DIR__ . '/../src/models/AdminModel.php';// AdminModel::getConfig()
|
||||||
|
require_once __DIR__ . '/../src/lib/ACL.php'; // ACL checks
|
||||||
|
require_once __DIR__ . '/../src/webdav/CurrentUser.php';
|
||||||
|
|
||||||
// ─── 1.1) Global WebDAV feature toggle ──────────────────────────────────────
|
// ─── 1.1) Global WebDAV feature toggle ──────────────────────────────────────
|
||||||
$adminConfig = AdminModel::getConfig();
|
$adminConfig = AdminModel::getConfig();
|
||||||
$enableWebDAV = isset($adminConfig['enableWebDAV']) && $adminConfig['enableWebDAV'];
|
$enableWebDAV = isset($adminConfig['enableWebDAV']) && $adminConfig['enableWebDAV'];
|
||||||
if (!$enableWebDAV) {
|
if (!$enableWebDAV) {
|
||||||
header('HTTP/1.1 403 Forbidden');
|
header('HTTP/1.1 403 Forbidden');
|
||||||
echo 'WebDAV access is currently disabled by administrator.';
|
echo 'WebDAV access is currently disabled by administrator.';
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 2) Load WebDAV directory implementation ──────────────────────────
|
// ─── 2) Load WebDAV directory implementation (ACL-aware) ────────────────────
|
||||||
require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php';
|
require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php';
|
||||||
|
|
||||||
use Sabre\DAV\Server;
|
use Sabre\DAV\Server;
|
||||||
use Sabre\DAV\Auth\Backend\BasicCallBack;
|
use Sabre\DAV\Auth\Backend\BasicCallBack;
|
||||||
use Sabre\DAV\Auth\Plugin as AuthPlugin;
|
use Sabre\DAV\Auth\Plugin as AuthPlugin;
|
||||||
use Sabre\DAV\Locks\Plugin as LocksPlugin;
|
use Sabre\DAV\Locks\Plugin as LocksPlugin;
|
||||||
use Sabre\DAV\Locks\Backend\File as LocksFileBackend;
|
use Sabre\DAV\Locks\Backend\File as LocksFileBackend;
|
||||||
use FileRise\WebDAV\FileRiseDirectory;
|
use FileRise\WebDAV\FileRiseDirectory;
|
||||||
|
use FileRise\WebDAV\CurrentUser;
|
||||||
|
|
||||||
// ─── 3) HTTP‑Basic backend ─────────────────────────────────────────────────
|
// ─── 3) HTTP-Basic backend (delegates to your AuthModel) ────────────────────
|
||||||
$authBackend = new BasicCallBack(function(string $user, string $pass) {
|
$authBackend = new BasicCallBack(function(string $user, string $pass) {
|
||||||
return \AuthModel::authenticate($user, $pass) !== false;
|
return \AuthModel::authenticate($user, $pass) !== false;
|
||||||
});
|
});
|
||||||
$authPlugin = new AuthPlugin($authBackend, 'FileRise');
|
$authPlugin = new AuthPlugin($authBackend, 'FileRise');
|
||||||
|
|
||||||
// ─── 4) Determine user scope ────────────────────────────────────────────────
|
// ─── 4) Resolve authenticated user + perms ──────────────────────────────────
|
||||||
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
|
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
|
||||||
$isAdmin = (\AuthModel::getUserRole($user) === '1');
|
if ($user === '') {
|
||||||
$folderOnly = (bool)\AuthModel::loadFolderPermission($user);
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
|
header('WWW-Authenticate: Basic realm="FileRise"');
|
||||||
if ($isAdmin || !$folderOnly) {
|
echo 'Authentication required.';
|
||||||
// Admins (or users without folder-only restriction) see the full /uploads
|
exit;
|
||||||
$rootPath = rtrim(UPLOAD_DIR, '/\\');
|
|
||||||
} else {
|
|
||||||
// Folder‑only users see only /uploads/{username}
|
|
||||||
$rootPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $user;
|
|
||||||
if (!is_dir($rootPath)) {
|
|
||||||
mkdir($rootPath, 0755, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 5) Spin up SabreDAV ────────────────────────────────────────────────────
|
$perms = is_callable('loadUserPermissions') ? (loadUserPermissions($user) ?: []) : [];
|
||||||
|
$isAdmin = (\AuthModel::getUserRole($user) === '1');
|
||||||
|
|
||||||
|
// set for metadata attribution in WebDAV writes
|
||||||
|
CurrentUser::set($user);
|
||||||
|
|
||||||
|
// ─── 5) Mount the real uploads root; ACL filters everything at node level ───
|
||||||
|
$rootPath = rtrim(UPLOAD_DIR, '/\\');
|
||||||
|
|
||||||
$server = new Server([
|
$server = new Server([
|
||||||
new FileRiseDirectory($rootPath, $user, $folderOnly),
|
new FileRiseDirectory($rootPath, $user, $isAdmin, $perms),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Auth + Locks
|
||||||
$server->addPlugin($authPlugin);
|
$server->addPlugin($authPlugin);
|
||||||
$server->addPlugin(
|
$server->addPlugin(
|
||||||
new LocksPlugin(
|
new LocksPlugin(
|
||||||
@@ -70,5 +76,8 @@ $server->addPlugin(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Base URI (adjust if you serve from a subdir or rewrite rule)
|
||||||
$server->setBaseUri('/webdav.php/');
|
$server->setBaseUri('/webdav.php/');
|
||||||
|
|
||||||
|
// Execute
|
||||||
$server->exec();
|
$server->exec();
|
||||||
@@ -5,148 +5,57 @@ require_once __DIR__ . '/../../config/config.php';
|
|||||||
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
||||||
|
|
||||||
class AdminController
|
class AdminController
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Get(
|
|
||||||
* path="/api/admin/getConfig.php",
|
|
||||||
* summary="Retrieve admin configuration",
|
|
||||||
* description="Returns the admin configuration settings, decrypting the configuration file and providing default values if not set.",
|
|
||||||
* operationId="getAdminConfig",
|
|
||||||
* tags={"Admin"},
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="Configuration retrieved successfully",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="header_title", type="string", example="FileRise"),
|
|
||||||
* @OA\Property(
|
|
||||||
* property="oidc",
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="providerUrl", type="string", example="https://your-oidc-provider.com"),
|
|
||||||
* @OA\Property(property="clientId", type="string", example="YOUR_CLIENT_ID"),
|
|
||||||
* @OA\Property(property="clientSecret", type="string", example="YOUR_CLIENT_SECRET"),
|
|
||||||
* @OA\Property(property="redirectUri", type="string", example="https://yourdomain.com/auth.php?oidc=callback")
|
|
||||||
* ),
|
|
||||||
* @OA\Property(
|
|
||||||
* property="loginOptions",
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="disableFormLogin", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="disableBasicAuth", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="disableOIDCLogin", type="boolean", example=false)
|
|
||||||
* ),
|
|
||||||
* @OA\Property(property="globalOtpauthUrl", type="string", example=""),
|
|
||||||
* @OA\Property(property="enableWebDAV", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="sharedMaxUploadSize", type="integer", example=52428800)
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=500,
|
|
||||||
* description="Failed to decrypt configuration or server error"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* Retrieves the admin configuration settings.
|
|
||||||
*
|
|
||||||
* @return void Outputs a JSON response with configuration data.
|
|
||||||
*/
|
|
||||||
public function getConfig(): void
|
public function getConfig(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
// Require authenticated admin to read config (prevents information disclosure)
|
// Load raw config (no disclosure yet)
|
||||||
if (
|
$config = AdminModel::getConfig();
|
||||||
empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
if (isset($config['error'])) {
|
||||||
empty($_SESSION['isAdmin'])
|
http_response_code(500);
|
||||||
) {
|
echo json_encode(['error' => $config['error']]);
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(['error' => 'Unauthorized access.']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$config = AdminModel::getConfig();
|
|
||||||
if (isset($config['error'])) {
|
|
||||||
http_response_code(500);
|
|
||||||
echo json_encode(['error' => $config['error']]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a safe subset for the front-end
|
|
||||||
$safe = [
|
|
||||||
'header_title' => $config['header_title'] ?? '',
|
|
||||||
'loginOptions' => $config['loginOptions'] ?? [],
|
|
||||||
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
|
|
||||||
'enableWebDAV' => $config['enableWebDAV'] ?? false,
|
|
||||||
'sharedMaxUploadSize' => $config['sharedMaxUploadSize'] ?? 0,
|
|
||||||
'oidc' => [
|
|
||||||
'providerUrl' => $config['oidc']['providerUrl'] ?? '',
|
|
||||||
'redirectUri' => $config['oidc']['redirectUri'] ?? '',
|
|
||||||
// clientSecret and clientId never exposed here
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
echo json_encode($safe);
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Minimal, safe subset for all callers (unauth users and regular users)
|
||||||
* @OA\Put(
|
$public = [
|
||||||
* path="/api/admin/updateConfig.php",
|
'header_title' => $config['header_title'] ?? 'FileRise',
|
||||||
* summary="Update admin configuration",
|
'loginOptions' => [
|
||||||
* description="Updates the admin configuration settings. Requires admin privileges and a valid CSRF token.",
|
// expose only what the login page / header needs
|
||||||
* operationId="updateAdminConfig",
|
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
|
||||||
* tags={"Admin"},
|
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
|
||||||
* @OA\RequestBody(
|
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
|
||||||
* required=true,
|
],
|
||||||
* @OA\JsonContent(
|
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
|
||||||
* required={"header_title", "oidc", "loginOptions"},
|
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
|
||||||
* @OA\Property(property="header_title", type="string", example="FileRise"),
|
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
|
||||||
* @OA\Property(
|
|
||||||
* property="oidc",
|
'oidc' => [
|
||||||
* type="object",
|
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||||
* @OA\Property(property="providerUrl", type="string", example="https://your-oidc-provider.com"),
|
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||||
* @OA\Property(property="clientId", type="string", example="YOUR_CLIENT_ID"),
|
// never expose clientId / clientSecret
|
||||||
* @OA\Property(property="clientSecret", type="string", example="YOUR_CLIENT_SECRET"),
|
],
|
||||||
* @OA\Property(property="redirectUri", type="string", example="https://yourdomain.com/api/auth/auth.php?oidc=callback")
|
];
|
||||||
* ),
|
|
||||||
* @OA\Property(
|
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
|
||||||
* property="loginOptions",
|
|
||||||
* type="object",
|
if ($isAdmin) {
|
||||||
* @OA\Property(property="disableFormLogin", type="boolean", example=false),
|
// Add admin-only fields (used by Admin Panel UI)
|
||||||
* @OA\Property(property="disableBasicAuth", type="boolean", example=false),
|
$adminExtra = [
|
||||||
* @OA\Property(property="disableOIDCLogin", type="boolean", example=false)
|
'loginOptions' => array_merge($public['loginOptions'], [
|
||||||
* ),
|
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
|
||||||
* @OA\Property(property="globalOtpauthUrl", type="string", example=""),
|
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
|
||||||
* @OA\Property(property="enableWebDAV", type="boolean", example=false),
|
]),
|
||||||
* @OA\Property(property="sharedMaxUploadSize", type="integer", example=52428800)
|
];
|
||||||
* )
|
echo json_encode(array_merge($public, $adminExtra));
|
||||||
* ),
|
return;
|
||||||
* @OA\Response(
|
}
|
||||||
* response=200,
|
|
||||||
* description="Configuration updated successfully",
|
// Non-admins / unauthenticated: only the public subset
|
||||||
* @OA\JsonContent(
|
echo json_encode($public);
|
||||||
* type="object",
|
}
|
||||||
* @OA\Property(property="success", type="string", example="Configuration updated successfully.")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="Bad Request (e.g., invalid input, incomplete OIDC configuration)"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=403,
|
|
||||||
* description="Unauthorized (user not admin or invalid CSRF token)"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=500,
|
|
||||||
* description="Server error (failed to write configuration file)"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* Updates the admin configuration settings.
|
|
||||||
*
|
|
||||||
* @return void Outputs a JSON response indicating success or failure.
|
|
||||||
*/
|
|
||||||
public function updateConfig(): void
|
public function updateConfig(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|||||||
@@ -13,53 +13,6 @@ use Jumbojett\OpenIDConnectClient;
|
|||||||
class AuthController
|
class AuthController
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Post(
|
|
||||||
* path="/api/auth/auth.php",
|
|
||||||
* summary="Authenticate user",
|
|
||||||
* description="Handles user authentication via OIDC or form-based credentials. For OIDC flows, processes callbacks; otherwise, performs standard authentication with optional TOTP verification.",
|
|
||||||
* operationId="authUser",
|
|
||||||
* tags={"Auth"},
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* required={"username", "password"},
|
|
||||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
|
||||||
* @OA\Property(property="password", type="string", example="secretpassword"),
|
|
||||||
* @OA\Property(property="remember_me", type="boolean", example=true),
|
|
||||||
* @OA\Property(property="totp_code", type="string", example="123456")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="Login successful; returns user info and status",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="status", type="string", example="ok"),
|
|
||||||
* @OA\Property(property="success", type="string", example="Login successful"),
|
|
||||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
|
||||||
* @OA\Property(property="isAdmin", type="boolean", example=true)
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="Bad Request (e.g., missing credentials)"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=401,
|
|
||||||
* description="Unauthorized (e.g., invalid credentials, too many attempts)"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=429,
|
|
||||||
* description="Too many failed login attempts"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* Handles user authentication via OIDC or form-based login.
|
|
||||||
*
|
|
||||||
* @return void Redirects on success or outputs JSON error.
|
|
||||||
*/
|
|
||||||
// in src/controllers/AuthController.php
|
|
||||||
|
|
||||||
public function auth(): void
|
public function auth(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -307,40 +260,6 @@ class AuthController
|
|||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Get(
|
|
||||||
* path="/api/auth/checkAuth.php",
|
|
||||||
* summary="Check authentication status",
|
|
||||||
* description="Checks if the current session is authenticated. If the users file is missing or empty, returns a setup flag. Also returns information about admin privileges, TOTP status, and folder-only access.",
|
|
||||||
* operationId="checkAuth",
|
|
||||||
* tags={"Auth"},
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="Returns authentication status and user details",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="authenticated", type="boolean", example=true),
|
|
||||||
* @OA\Property(property="isAdmin", type="boolean", example=true),
|
|
||||||
* @OA\Property(property="totp_enabled", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
|
||||||
* @OA\Property(property="folderOnly", type="boolean", example=false)
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="Setup mode (if the users file is missing or empty)",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="setup", type="boolean", example=true)
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* Checks whether the user is authenticated or if the system is in setup mode.
|
|
||||||
*
|
|
||||||
* @return void Outputs a JSON response with authentication details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
public function checkAuth(): void
|
public function checkAuth(): void
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -427,28 +346,6 @@ class AuthController
|
|||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Get(
|
|
||||||
* path="/api/auth/token.php",
|
|
||||||
* summary="Retrieve CSRF token and share URL",
|
|
||||||
* description="Returns the current CSRF token along with the configured share URL.",
|
|
||||||
* operationId="getToken",
|
|
||||||
* tags={"Auth"},
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="CSRF token and share URL",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="csrf_token", type="string", example="0123456789abcdef..."),
|
|
||||||
* @OA\Property(property="share_url", type="string", example="https://yourdomain.com/share.php")
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* Returns the CSRF token and share URL.
|
|
||||||
*
|
|
||||||
* @return void Outputs the JSON response.
|
|
||||||
*/
|
|
||||||
public function getToken(): void
|
public function getToken(): void
|
||||||
{
|
{
|
||||||
// 1) Ensure session and CSRF token exist
|
// 1) Ensure session and CSRF token exist
|
||||||
@@ -468,31 +365,6 @@ class AuthController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Get(
|
|
||||||
* path="/api/auth/login_basic.php",
|
|
||||||
* summary="Authenticate using HTTP Basic Authentication",
|
|
||||||
* description="Performs HTTP Basic authentication. If credentials are missing, sends a 401 response prompting for Basic auth. On valid credentials, optionally handles TOTP verification and finalizes session login.",
|
|
||||||
* operationId="loginBasic",
|
|
||||||
* tags={"Auth"},
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="Login successful; redirects to index.html",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="success", type="string", example="Login successful")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=401,
|
|
||||||
* description="Unauthorized due to missing credentials or invalid credentials."
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* Handles HTTP Basic authentication (with optional TOTP) and logs the user in.
|
|
||||||
*
|
|
||||||
* @return void Redirects on success or sends a 401 header.
|
|
||||||
*/
|
|
||||||
public function loginBasic(): void
|
public function loginBasic(): void
|
||||||
{
|
{
|
||||||
// Set header for plain-text or JSON as needed.
|
// Set header for plain-text or JSON as needed.
|
||||||
@@ -550,27 +422,6 @@ class AuthController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Post(
|
|
||||||
* path="/api/auth/logout.php",
|
|
||||||
* summary="Logout user",
|
|
||||||
* description="Clears the session, removes persistent login tokens, and redirects the user to the login page.",
|
|
||||||
* operationId="logoutUser",
|
|
||||||
* tags={"Auth"},
|
|
||||||
* @OA\Response(
|
|
||||||
* response=302,
|
|
||||||
* description="Redirects to the login page with a logout flag."
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=401,
|
|
||||||
* description="Unauthorized"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* Logs the user out by clearing session data, removing persistent tokens, and destroying the session.
|
|
||||||
*
|
|
||||||
* @return void Redirects to index.html with a logout flag.
|
|
||||||
*/
|
|
||||||
public function logout(): void
|
public function logout(): void
|
||||||
{
|
{
|
||||||
// Retrieve headers and check CSRF token.
|
// Retrieve headers and check CSRF token.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,198 +2,107 @@
|
|||||||
// 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 {
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Post(
|
|
||||||
* path="/api/upload/upload.php",
|
|
||||||
* summary="Handle file upload",
|
|
||||||
* description="Handles file uploads for both chunked and non-chunked (full) uploads. Validates CSRF, user authentication, and permissions, and processes file uploads accordingly. On success, returns a JSON status for chunked uploads or redirects for full uploads.",
|
|
||||||
* operationId="handleUpload",
|
|
||||||
* tags={"Uploads"},
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
* description="Multipart form data for file upload. For chunked uploads, include fields like 'resumableChunkNumber', 'resumableTotalChunks', 'resumableIdentifier', 'resumableFilename', etc.",
|
|
||||||
* @OA\MediaType(
|
|
||||||
* mediaType="multipart/form-data",
|
|
||||||
* @OA\Schema(
|
|
||||||
* required={"token", "fileToUpload"},
|
|
||||||
* @OA\Property(property="token", type="string", description="Share token or upload token."),
|
|
||||||
* @OA\Property(
|
|
||||||
* property="fileToUpload",
|
|
||||||
* type="string",
|
|
||||||
* format="binary",
|
|
||||||
* description="The file to upload."
|
|
||||||
* ),
|
|
||||||
* @OA\Property(property="resumableChunkNumber", type="integer", description="Chunk number for chunked uploads."),
|
|
||||||
* @OA\Property(property="resumableTotalChunks", type="integer", description="Total number of chunks."),
|
|
||||||
* @OA\Property(property="resumableFilename", type="string", description="Original filename."),
|
|
||||||
* @OA\Property(property="folder", type="string", description="Target folder (default 'root').")
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="File uploaded successfully (or chunk uploaded status).",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="success", type="string", example="File uploaded successfully"),
|
|
||||||
* @OA\Property(property="newFilename", type="string", example="5f2d7c123a_example.png"),
|
|
||||||
* @OA\Property(property="status", type="string", example="chunk uploaded")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=302,
|
|
||||||
* description="Redirection on full upload success."
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="Bad Request (e.g., missing file, invalid parameters)"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=401,
|
|
||||||
* description="Unauthorized"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=403,
|
|
||||||
* description="Forbidden (e.g., invalid CSRF token, upload disabled)"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=500,
|
|
||||||
* description="Server error during file processing"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* Handles file uploads, both chunked and full, and redirects upon success.
|
|
||||||
*
|
|
||||||
* @return void Outputs JSON response (for chunked uploads) or redirects on successful full upload.
|
|
||||||
*/
|
|
||||||
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::canUpload($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
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Post(
|
|
||||||
* path="/api/upload/removeChunks.php",
|
|
||||||
* summary="Remove chunked upload temporary directory",
|
|
||||||
* description="Removes the temporary directory used for chunked uploads, given a folder name matching the expected resumable pattern.",
|
|
||||||
* operationId="removeChunks",
|
|
||||||
* tags={"Uploads"},
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* required={"folder"},
|
|
||||||
* @OA\Property(property="folder", type="string", example="resumable_myupload123")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="Temporary folder removed successfully",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="success", type="boolean", example=true),
|
|
||||||
* @OA\Property(property="message", type="string", example="Temporary folder removed.")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="Invalid input (e.g., missing folder or invalid folder name)"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=403,
|
|
||||||
* description="Invalid CSRF token"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* Removes the temporary upload folder for chunked uploads.
|
|
||||||
*
|
|
||||||
* @return void Outputs a JSON response.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,16 +60,37 @@ class UserController
|
|||||||
|
|
||||||
/** Enforce admin (401). */
|
/** Enforce admin (401). */
|
||||||
private static function requireAdmin(): void
|
private static function requireAdmin(): void
|
||||||
{
|
{
|
||||||
self::requireAuth();
|
self::requireAuth();
|
||||||
if (empty($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) {
|
|
||||||
http_response_code(401);
|
// Prefer the session flag
|
||||||
header('Content-Type: application/json');
|
$isAdmin = (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true);
|
||||||
echo json_encode(['error' => 'Unauthorized']);
|
|
||||||
exit;
|
// 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). */
|
/** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
|
||||||
private static function requireCsrf(): void
|
private static function requireCsrf(): void
|
||||||
{
|
{
|
||||||
@@ -101,31 +122,6 @@ class UserController
|
|||||||
|
|
||||||
/* ------------------------- End helpers -------------------------- */
|
/* ------------------------- End helpers -------------------------- */
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Get(
|
|
||||||
* path="/api/getUsers.php",
|
|
||||||
* summary="Retrieve a list of users",
|
|
||||||
* description="Returns a JSON array of users. Only available to authenticated admin users.",
|
|
||||||
* operationId="getUsers",
|
|
||||||
* tags={"Users"},
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="Successful response with an array of users",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="array",
|
|
||||||
* @OA\Items(
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
|
||||||
* @OA\Property(property="role", type="string", example="admin")
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=401,
|
|
||||||
* description="Unauthorized: the user is not authenticated or is not an admin"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function getUsers()
|
public function getUsers()
|
||||||
{
|
{
|
||||||
self::jsonHeaders();
|
self::jsonHeaders();
|
||||||
@@ -137,39 +133,6 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Post(
|
|
||||||
* path="/api/addUser.php",
|
|
||||||
* summary="Add a new user",
|
|
||||||
* description="Adds a new user to the system. In setup mode, the new user is automatically made admin.",
|
|
||||||
* operationId="addUser",
|
|
||||||
* tags={"Users"},
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* required={"username", "password"},
|
|
||||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
|
||||||
* @OA\Property(property="password", type="string", example="securepassword"),
|
|
||||||
* @OA\Property(property="isAdmin", type="boolean", example=true)
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="User added successfully",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="success", type="string", example="User added successfully")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="Bad Request"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=401,
|
|
||||||
* description="Unauthorized"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function addUser()
|
public function addUser()
|
||||||
{
|
{
|
||||||
self::jsonHeaders();
|
self::jsonHeaders();
|
||||||
@@ -237,41 +200,6 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Delete(
|
|
||||||
* path="/api/removeUser.php",
|
|
||||||
* summary="Remove a user",
|
|
||||||
* description="Removes the specified user from the system. Cannot remove the currently logged-in user.",
|
|
||||||
* operationId="removeUser",
|
|
||||||
* tags={"Users"},
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* required={"username"},
|
|
||||||
* @OA\Property(property="username", type="string", example="johndoe")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="User removed successfully",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="success", type="string", example="User removed successfully")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="Bad Request"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=401,
|
|
||||||
* description="Unauthorized"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=403,
|
|
||||||
* description="Invalid CSRF token"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function removeUser()
|
public function removeUser()
|
||||||
{
|
{
|
||||||
self::jsonHeaders();
|
self::jsonHeaders();
|
||||||
@@ -301,24 +229,6 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Get(
|
|
||||||
* path="/api/getUserPermissions.php",
|
|
||||||
* summary="Retrieve user permissions",
|
|
||||||
* description="Returns the permissions for the current user, or all permissions if the user is an admin.",
|
|
||||||
* operationId="getUserPermissions",
|
|
||||||
* tags={"Users"},
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="Successful response with user permissions",
|
|
||||||
* @OA\JsonContent(type="object")
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=401,
|
|
||||||
* description="Unauthorized"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function getUserPermissions()
|
public function getUserPermissions()
|
||||||
{
|
{
|
||||||
self::jsonHeaders();
|
self::jsonHeaders();
|
||||||
@@ -329,51 +239,6 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Put(
|
|
||||||
* path="/api/updateUserPermissions.php",
|
|
||||||
* summary="Update user permissions",
|
|
||||||
* description="Updates permissions for users. Only available to authenticated admin users.",
|
|
||||||
* operationId="updateUserPermissions",
|
|
||||||
* tags={"Users"},
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* required={"permissions"},
|
|
||||||
* @OA\Property(
|
|
||||||
* property="permissions",
|
|
||||||
* type="array",
|
|
||||||
* @OA\Items(
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
|
||||||
* @OA\Property(property="folderOnly", type="boolean", example=true),
|
|
||||||
* @OA\Property(property="readOnly", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="disableUpload", type="boolean", example=false)
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="User permissions updated successfully",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="success", type="string", example="User permissions updated successfully.")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=401,
|
|
||||||
* description="Unauthorized"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=403,
|
|
||||||
* description="Invalid CSRF token"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="Bad Request"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function updateUserPermissions()
|
public function updateUserPermissions()
|
||||||
{
|
{
|
||||||
self::jsonHeaders();
|
self::jsonHeaders();
|
||||||
@@ -394,43 +259,6 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Post(
|
|
||||||
* path="/api/changePassword.php",
|
|
||||||
* summary="Change user password",
|
|
||||||
* description="Allows an authenticated user to change their password by verifying the old password and updating to a new one.",
|
|
||||||
* operationId="changePassword",
|
|
||||||
* tags={"Users"},
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* required={"oldPassword", "newPassword", "confirmPassword"},
|
|
||||||
* @OA\Property(property="oldPassword", type="string", example="oldpass123"),
|
|
||||||
* @OA\Property(property="newPassword", type="string", example="newpass456"),
|
|
||||||
* @OA\Property(property="confirmPassword", type="string", example="newpass456")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="Password updated successfully",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="success", type="string", example="Password updated successfully.")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="Bad Request"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=401,
|
|
||||||
* description="Unauthorized"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=403,
|
|
||||||
* description="Invalid CSRF token"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function changePassword()
|
public function changePassword()
|
||||||
{
|
{
|
||||||
self::jsonHeaders();
|
self::jsonHeaders();
|
||||||
@@ -467,41 +295,6 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Put(
|
|
||||||
* path="/api/updateUserPanel.php",
|
|
||||||
* summary="Update user panel settings",
|
|
||||||
* description="Updates user panel settings by disabling TOTP when not enabled. Accessible to authenticated users.",
|
|
||||||
* operationId="updateUserPanel",
|
|
||||||
* tags={"Users"},
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* required={"totp_enabled"},
|
|
||||||
* @OA\Property(property="totp_enabled", type="boolean", example=false)
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="User panel updated successfully",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="success", type="string", example="User panel updated: TOTP disabled")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=401,
|
|
||||||
* description="Unauthorized"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=403,
|
|
||||||
* description="Invalid CSRF token"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="Bad Request"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function updateUserPanel()
|
public function updateUserPanel()
|
||||||
{
|
{
|
||||||
self::jsonHeaders();
|
self::jsonHeaders();
|
||||||
@@ -530,31 +323,6 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Put(
|
|
||||||
* path="/api/totp_disable.php",
|
|
||||||
* summary="Disable TOTP for the authenticated user",
|
|
||||||
* description="Clears the TOTP secret from the users file for the current user.",
|
|
||||||
* operationId="disableTOTP",
|
|
||||||
* tags={"TOTP"},
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="TOTP disabled successfully",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="success", type="boolean", example=true),
|
|
||||||
* @OA\Property(property="message", type="string", example="TOTP disabled successfully.")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=403,
|
|
||||||
* description="Not authenticated or invalid CSRF token"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=500,
|
|
||||||
* description="Failed to disable TOTP"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function disableTOTP()
|
public function disableTOTP()
|
||||||
{
|
{
|
||||||
self::jsonHeaders();
|
self::jsonHeaders();
|
||||||
@@ -580,45 +348,6 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Post(
|
|
||||||
* path="/api/totp_recover.php",
|
|
||||||
* summary="Recover TOTP",
|
|
||||||
* description="Verifies a recovery code to disable TOTP and finalize login.",
|
|
||||||
* operationId="recoverTOTP",
|
|
||||||
* tags={"TOTP"},
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* required={"recovery_code"},
|
|
||||||
* @OA\Property(property="recovery_code", type="string", example="ABC123DEF456")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="Recovery successful",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="status", type="string", example="ok")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="Invalid input or recovery code"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=403,
|
|
||||||
* description="Invalid CSRF token"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=405,
|
|
||||||
* description="Method not allowed"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=429,
|
|
||||||
* description="Too many attempts"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function recoverTOTP()
|
public function recoverTOTP()
|
||||||
{
|
{
|
||||||
self::jsonHeaders();
|
self::jsonHeaders();
|
||||||
@@ -660,35 +389,6 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Post(
|
|
||||||
* path="/api/totp_saveCode.php",
|
|
||||||
* summary="Generate and save a new TOTP recovery code",
|
|
||||||
* description="Generates a new TOTP recovery code for the authenticated user, stores its hash, and returns the plain text recovery code.",
|
|
||||||
* operationId="totpSaveCode",
|
|
||||||
* tags={"TOTP"},
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="Recovery code generated successfully",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="status", type="string", example="ok"),
|
|
||||||
* @OA\Property(property="recoveryCode", type="string", example="ABC123DEF456")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="Bad Request"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=403,
|
|
||||||
* description="Invalid CSRF token or unauthorized"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=405,
|
|
||||||
* description="Method not allowed"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function saveTOTPRecoveryCode()
|
public function saveTOTPRecoveryCode()
|
||||||
{
|
{
|
||||||
self::jsonHeaders();
|
self::jsonHeaders();
|
||||||
@@ -718,30 +418,6 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Get(
|
|
||||||
* path="/api/totp_setup.php",
|
|
||||||
* summary="Set up TOTP and generate a QR code",
|
|
||||||
* description="Generates (or retrieves) the TOTP secret for the user and builds a QR code image for scanning.",
|
|
||||||
* operationId="setupTOTP",
|
|
||||||
* tags={"TOTP"},
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="QR code image for TOTP setup",
|
|
||||||
* @OA\MediaType(
|
|
||||||
* mediaType="image/png"
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=403,
|
|
||||||
* description="Unauthorized or invalid CSRF token"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=500,
|
|
||||||
* description="Server error"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function setupTOTP()
|
public function setupTOTP()
|
||||||
{
|
{
|
||||||
// Allow access if authenticated OR pending TOTP
|
// Allow access if authenticated OR pending TOTP
|
||||||
@@ -778,42 +454,6 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Post(
|
|
||||||
* path="/api/totp_verify.php",
|
|
||||||
* summary="Verify TOTP code",
|
|
||||||
* description="Verifies a TOTP code and completes login for pending users or validates TOTP for setup verification.",
|
|
||||||
* operationId="verifyTOTP",
|
|
||||||
* tags={"TOTP"},
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* required={"totp_code"},
|
|
||||||
* @OA\Property(property="totp_code", type="string", example="123456")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="TOTP successfully verified",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* @OA\Property(property="status", type="string", example="ok"),
|
|
||||||
* @OA\Property(property="message", type="string", example="Login successful")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="Bad Request (e.g., invalid input)"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=403,
|
|
||||||
* description="Not authenticated or invalid CSRF token"
|
|
||||||
* ),
|
|
||||||
* @OA\Response(
|
|
||||||
* response=429,
|
|
||||||
* description="Too many attempts. Try again later."
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function verifyTOTP()
|
public function verifyTOTP()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|||||||
457
src/lib/ACL.php
Normal file
457
src/lib/ACL.php
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
<?php
|
||||||
|
// src/lib/ACL.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
|
||||||
|
class ACL
|
||||||
|
{
|
||||||
|
private static $cache = null;
|
||||||
|
private static $path = null;
|
||||||
|
|
||||||
|
private const BUCKETS = [
|
||||||
|
'owners','read','write','share','read_own',
|
||||||
|
'create','upload','edit','rename','copy','move','delete','extract',
|
||||||
|
'share_file','share_folder'
|
||||||
|
];
|
||||||
|
|
||||||
|
private static function path(): string {
|
||||||
|
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||||
|
return self::$path;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = is_array($rec[$k] ?? null) ? $rec[$k] : [];
|
||||||
|
$rec[$k] = array_values(array_filter($before, fn($u) => strcasecmp((string)$u, $user) !== 0));
|
||||||
|
if ($rec[$k] !== $before) $changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($rec);
|
||||||
|
return $changed ? self::save($acl) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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'=> [],
|
||||||
|
'create' => [],
|
||||||
|
'upload' => [],
|
||||||
|
'edit' => [],
|
||||||
|
'rename' => [],
|
||||||
|
'copy' => [],
|
||||||
|
'move' => [],
|
||||||
|
'delete' => [],
|
||||||
|
'extract' => [],
|
||||||
|
'share_file' => [],
|
||||||
|
'share_folder' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'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 = [];
|
||||||
|
$data['folders'] = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
|
||||||
|
$data['groups'] = isset($data['groups']) && is_array($data['groups']) ? $data['groups'] : [];
|
||||||
|
|
||||||
|
if (!isset($data['folders']['root']) || !is_array($data['folders']['root'])) {
|
||||||
|
$data['folders']['root'] = [
|
||||||
|
'owners' => ['admin'],
|
||||||
|
'read' => ['admin'],
|
||||||
|
'write' => ['admin'],
|
||||||
|
'share' => ['admin'],
|
||||||
|
'read_own' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$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;
|
||||||
|
if ($healed) @file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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' => [],
|
||||||
|
'create' => [],
|
||||||
|
'upload' => [],
|
||||||
|
'edit' => [],
|
||||||
|
'rename' => [],
|
||||||
|
'copy' => [],
|
||||||
|
'move' => [],
|
||||||
|
'delete' => [],
|
||||||
|
'extract' => [],
|
||||||
|
'share_file' => [],
|
||||||
|
'share_folder' => [],
|
||||||
|
];
|
||||||
|
self::save($acl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
|
||||||
|
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isOwner(string $user, array $perms, string $folder): bool {
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'read');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canReadOwn(string $user, array $perms, string $folder): bool {
|
||||||
|
if (self::canRead($user, $perms, $folder)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'read_own');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy-only explicit (to avoid breaking existing callers)
|
||||||
|
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'] ?? []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// New: full explicit including granular
|
||||||
|
public static function explicitAll(string $folder): array {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
|
$rec = $acl['folders'][$folder] ?? [];
|
||||||
|
$norm = function ($v): array {
|
||||||
|
if (!is_array($v)) return [];
|
||||||
|
$v = array_map('strval', $v);
|
||||||
|
return array_values(array_unique($v));
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
'owners' => $norm($rec['owners'] ?? []),
|
||||||
|
'read' => $norm($rec['read'] ?? []),
|
||||||
|
'write' => $norm($rec['write'] ?? []),
|
||||||
|
'share' => $norm($rec['share'] ?? []),
|
||||||
|
'read_own' => $norm($rec['read_own'] ?? []),
|
||||||
|
'create' => $norm($rec['create'] ?? []),
|
||||||
|
'upload' => $norm($rec['upload'] ?? []),
|
||||||
|
'edit' => $norm($rec['edit'] ?? []),
|
||||||
|
'rename' => $norm($rec['rename'] ?? []),
|
||||||
|
'copy' => $norm($rec['copy'] ?? []),
|
||||||
|
'move' => $norm($rec['move'] ?? []),
|
||||||
|
'delete' => $norm($rec['delete'] ?? []),
|
||||||
|
'extract' => $norm($rec['extract'] ?? []),
|
||||||
|
'share_file' => $norm($rec['share_file'] ?? []),
|
||||||
|
'share_folder' => $norm($rec['share_folder'] ?? []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
$acl = self::$cache ?? self::loadFresh();
|
||||||
|
$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),
|
||||||
|
'read_own' => isset($existing['read_own']) && is_array($existing['read_own'])
|
||||||
|
? array_values(array_unique(array_map('strval', $existing['read_own'])))
|
||||||
|
: [],
|
||||||
|
'create' => isset($existing['create']) && is_array($existing['create']) ? array_values(array_unique(array_map('strval', $existing['create']))) : [],
|
||||||
|
'upload' => isset($existing['upload']) && is_array($existing['upload']) ? array_values(array_unique(array_map('strval', $existing['upload']))) : [],
|
||||||
|
'edit' => isset($existing['edit']) && is_array($existing['edit']) ? array_values(array_unique(array_map('strval', $existing['edit']))) : [],
|
||||||
|
'rename' => isset($existing['rename']) && is_array($existing['rename']) ? array_values(array_unique(array_map('strval', $existing['rename']))) : [],
|
||||||
|
'copy' => isset($existing['copy']) && is_array($existing['copy']) ? array_values(array_unique(array_map('strval', $existing['copy']))) : [],
|
||||||
|
'move' => isset($existing['move']) && is_array($existing['move']) ? array_values(array_unique(array_map('strval', $existing['move']))) : [],
|
||||||
|
'delete' => isset($existing['delete']) && is_array($existing['delete']) ? array_values(array_unique(array_map('strval', $existing['delete']))) : [],
|
||||||
|
'extract' => isset($existing['extract']) && is_array($existing['extract']) ? array_values(array_unique(array_map('strval', $existing['extract']))) : [],
|
||||||
|
'share_file' => isset($existing['share_file']) && is_array($existing['share_file']) ? array_values(array_unique(array_map('strval', $existing['share_file']))) : [],
|
||||||
|
'share_folder' => isset($existing['share_folder']) && is_array($existing['share_folder']) ? array_values(array_unique(array_map('strval', $existing['share_folder']))) : [],
|
||||||
|
];
|
||||||
|
return self::save($acl);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
$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] = [];
|
||||||
|
$rec =& $acl['folders'][$ff];
|
||||||
|
|
||||||
|
foreach (self::BUCKETS as $k) {
|
||||||
|
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
|
||||||
|
}
|
||||||
|
foreach (self::BUCKETS as $k) {
|
||||||
|
$arr = is_array($rec[$k]) ? $rec[$k] : [];
|
||||||
|
$rec[$k] = array_values(array_filter(
|
||||||
|
array_map('strval', $arr),
|
||||||
|
fn($u) => strcasecmp((string)$u, $user) !== 0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$v = !empty($caps['view']);
|
||||||
|
$vo = !empty($caps['viewOwn']);
|
||||||
|
$u = !empty($caps['upload']);
|
||||||
|
$m = !empty($caps['manage']);
|
||||||
|
$s = !empty($caps['share']);
|
||||||
|
$w = !empty($caps['write']);
|
||||||
|
|
||||||
|
$c = !empty($caps['create']);
|
||||||
|
$ed = !empty($caps['edit']);
|
||||||
|
$rn = !empty($caps['rename']);
|
||||||
|
$cp = !empty($caps['copy']);
|
||||||
|
$mv = !empty($caps['move']);
|
||||||
|
$dl = !empty($caps['delete']);
|
||||||
|
$ex = !empty($caps['extract']);
|
||||||
|
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
|
||||||
|
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
|
||||||
|
|
||||||
|
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $mv = $dl = $ex = $sf = $sfo = true; }
|
||||||
|
if ($u && !$v && !$vo) $vo = true;
|
||||||
|
//if ($s && !$v) $v = true;
|
||||||
|
if ($w) { $c = $u = $ed = $rn = $cp = $mv = $dl = $ex = true; }
|
||||||
|
|
||||||
|
if ($m) $rec['owners'][] = $user;
|
||||||
|
if ($v) $rec['read'][] = $user;
|
||||||
|
if ($vo) $rec['read_own'][] = $user;
|
||||||
|
if ($w) $rec['write'][] = $user;
|
||||||
|
if ($s) $rec['share'][] = $user;
|
||||||
|
|
||||||
|
if ($u) $rec['upload'][] = $user;
|
||||||
|
if ($c) $rec['create'][] = $user;
|
||||||
|
if ($ed) $rec['edit'][] = $user;
|
||||||
|
if ($rn) $rec['rename'][] = $user;
|
||||||
|
if ($cp) $rec['copy'][] = $user;
|
||||||
|
if ($mv) $rec['move'][] = $user;
|
||||||
|
if ($dl) $rec['delete'][] = $user;
|
||||||
|
if ($ex) $rec['extract'][] = $user;
|
||||||
|
if ($sf) $rec['share_file'][] = $user;
|
||||||
|
if ($sfo)$rec['share_folder'][] = $user;
|
||||||
|
|
||||||
|
foreach (self::BUCKETS as $k) {
|
||||||
|
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed[] = $ff;
|
||||||
|
unset($rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Granular write family -----------------------------------------------
|
||||||
|
|
||||||
|
public static function canCreate(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'create')
|
||||||
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreateFolder(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
// Only owners/managers can create subfolders under $folder
|
||||||
|
return self::hasGrant($user, $folder, 'owners');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canUpload(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'upload')
|
||||||
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canEdit(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'edit')
|
||||||
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canRename(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'rename')
|
||||||
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCopy(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'copy')
|
||||||
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canMove(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'move')
|
||||||
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'delete')
|
||||||
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canExtract(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners')
|
||||||
|
|| self::hasGrant($user, $folder, 'extract')
|
||||||
|
|| self::hasGrant($user, $folder, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sharing: files use share, folders require share + full-view. */
|
||||||
|
public static function canShareFile(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||||
|
}
|
||||||
|
public static function canShareFolder(string $user, array $perms, string $folder): bool {
|
||||||
|
$folder = self::normalizeFolder($folder);
|
||||||
|
if (self::isAdmin($perms)) return true;
|
||||||
|
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||||
|
if (!$can) return false;
|
||||||
|
// require full view too
|
||||||
|
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'read');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// 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 {
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,99 @@
|
|||||||
// 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
|
||||||
{
|
{
|
||||||
|
/* ============================================================
|
||||||
|
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** Load the folder → owner map. */
|
||||||
|
public static function getFolderOwners(): array
|
||||||
|
{
|
||||||
|
$f = FOLDER_OWNERS_FILE;
|
||||||
|
if (!file_exists($f)) return [];
|
||||||
|
$json = json_decode(@file_get_contents($f), true);
|
||||||
|
return is_array($json) ? $json : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the folder → owner map. */
|
||||||
|
public static function saveFolderOwners(array $map): bool
|
||||||
|
{
|
||||||
|
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 {
|
||||||
|
$rebased[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::saveFolderOwners($rebased);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Existing helpers
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a (possibly nested) relative folder like "invoices/2025" to a real path
|
* 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
|
* under UPLOAD_DIR. Validates each path segment against REGEX_FOLDER_NAME, enforces
|
||||||
@@ -59,9 +149,7 @@ class FolderModel
|
|||||||
return [$real, $relative, null];
|
return [$real, $relative, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Build metadata file path for a given (relative) folder. */
|
||||||
* 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) === '') {
|
||||||
@@ -72,42 +160,82 @@ class FolderModel
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a folder under the specified parent (or in root) and creates an empty metadata file.
|
* 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).
|
||||||
*/
|
*/
|
||||||
public static function createFolder(string $folderName, string $parent = ""): array
|
|
||||||
|
/**
|
||||||
|
* 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, string $creator): array
|
||||||
{
|
{
|
||||||
|
// -------- Normalize incoming values (use ONLY the parameters) --------
|
||||||
|
$folderName = trim((string)$folderName);
|
||||||
|
$parentIn = trim((string)$parent);
|
||||||
|
|
||||||
|
// If the client sent a path in folderName (e.g., "bob/new-sub") and parent is root/empty,
|
||||||
|
// derive parent = "bob" and folderName = "new-sub" so permission checks hit "bob".
|
||||||
|
$normalized = ACL::normalizeFolder($folderName);
|
||||||
|
if ($normalized !== 'root' && strpos($normalized, '/') !== false &&
|
||||||
|
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)) {
|
||||||
|
$parentIn = trim(str_replace('\\', '/', dirname($normalized)), '/');
|
||||||
|
$folderName = basename($normalized);
|
||||||
|
if ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) $parentIn = 'root';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
|
||||||
$folderName = trim($folderName);
|
$folderName = trim($folderName);
|
||||||
$parent = trim($parent);
|
if ($folderName === '') return ['success'=>false, 'error' => 'Folder name required'];
|
||||||
|
|
||||||
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
// ACL key for new folder
|
||||||
return ["error" => "Invalid folder name."];
|
$newKey = ($parent === 'root') ? $folderName : ($parent . '/' . $folderName);
|
||||||
|
|
||||||
|
// -------- Compose filesystem paths --------
|
||||||
|
$base = rtrim((string)UPLOAD_DIR, "/\\");
|
||||||
|
$parentRel = ($parent === 'root') ? '' : str_replace('/', DIRECTORY_SEPARATOR, $parent);
|
||||||
|
$parentAbs = $parentRel ? ($base . DIRECTORY_SEPARATOR . $parentRel) : $base;
|
||||||
|
$newAbs = $parentAbs . DIRECTORY_SEPARATOR . $folderName;
|
||||||
|
|
||||||
|
// -------- Exists / sanity checks --------
|
||||||
|
if (!is_dir($parentAbs)) return ['success'=>false, 'error' => 'Parent folder does not exist'];
|
||||||
|
if (is_dir($newAbs)) return ['success'=>false, 'error' => 'Folder already exists'];
|
||||||
|
|
||||||
|
// -------- Create directory --------
|
||||||
|
if (!@mkdir($newAbs, 0775, true)) {
|
||||||
|
$err = error_get_last();
|
||||||
|
return ['success'=>false, 'error' => 'Failed to create folder' . (!empty($err['message']) ? (': '.$err['message']) : '')];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve parent path (root ok; nested ok)
|
// -------- Seed ACL --------
|
||||||
[$parentReal, $parentRel, $err] = self::resolveFolderPath($parent === '' ? 'root' : $parent, true);
|
$inherit = defined('ACL_INHERIT_ON_CREATE') && ACL_INHERIT_ON_CREATE;
|
||||||
if ($err) return ["error" => $err];
|
try {
|
||||||
|
if ($inherit) {
|
||||||
$targetRel = ($parentRel === 'root') ? $folderName : ($parentRel . '/' . $folderName);
|
// Copy parent’s explicit (legacy 5 buckets), add creator to owners
|
||||||
$targetDir = $parentReal . DIRECTORY_SEPARATOR . $folderName;
|
$p = ACL::explicit($parent); // owners, read, write, share, read_own
|
||||||
|
$owners = array_values(array_unique(array_map('strval', array_merge($p['owners'], [$creator]))));
|
||||||
if (file_exists($targetDir)) {
|
$read = $p['read'];
|
||||||
return ["error" => "Folder already exists."];
|
$write = $p['write'];
|
||||||
|
$share = $p['share'];
|
||||||
|
ACL::upsert($newKey, $owners, $read, $write, $share);
|
||||||
|
} else {
|
||||||
|
// Creator owns the new folder
|
||||||
|
ACL::ensureFolderRecord($newKey, $creator);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Roll back FS if ACL seeding fails
|
||||||
|
@rmdir($newAbs);
|
||||||
|
return ['success'=>false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mkdir($targetDir, 0775, true)) {
|
return ['success' => true, 'folder' => $newKey];
|
||||||
return ["error" => "Failed to create folder."];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an empty metadata file for the new folder.
|
|
||||||
$metadataFile = self::getMetadataFilePath($targetRel);
|
|
||||||
if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
|
||||||
return ["error" => "Folder created but failed to create metadata file."];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ["success" => true];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
public static function deleteFolder(string $folder): array
|
public static function deleteFolder(string $folder): array
|
||||||
{
|
{
|
||||||
@@ -119,12 +247,12 @@ class FolderModel
|
|||||||
if ($err) return ["error" => $err];
|
if ($err) return ["error" => $err];
|
||||||
|
|
||||||
// Prevent deletion if not empty.
|
// Prevent deletion if not empty.
|
||||||
$items = array_diff(scandir($real), array('.', '..'));
|
$items = array_diff(@scandir($real) ?: [], array('.', '..'));
|
||||||
if (count($items) > 0) {
|
if (count($items) > 0) {
|
||||||
return ["error" => "Folder is not empty."];
|
return ["error" => "Folder is not empty."];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rmdir($real)) {
|
if (!@rmdir($real)) {
|
||||||
return ["error" => "Failed to delete folder."];
|
return ["error" => "Failed to delete folder."];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,11 +262,15 @@ class FolderModel
|
|||||||
@unlink($metadataFile);
|
@unlink($metadataFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove ownership mappings for the subtree.
|
||||||
|
self::removeOwnerForTree($relative);
|
||||||
|
|
||||||
return ["success" => true];
|
return ["success" => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renames a folder and updates related metadata files (by renaming their filenames).
|
* Renames a folder and updates related metadata files (by renaming their filenames).
|
||||||
|
* Also rewrites ownership keys for the whole subtree from old → new.
|
||||||
*/
|
*/
|
||||||
public static function renameFolder(string $oldFolder, string $newFolder): array
|
public static function renameFolder(string $oldFolder, string $newFolder): array
|
||||||
{
|
{
|
||||||
@@ -163,6 +295,7 @@ class FolderModel
|
|||||||
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
|
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
|
||||||
|
|
||||||
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p!=='');
|
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p!=='');
|
||||||
|
$newRel = implode('/', $newParts);
|
||||||
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
|
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
|
||||||
|
|
||||||
// Parent of new path must exist
|
// Parent of new path must exist
|
||||||
@@ -174,13 +307,13 @@ class FolderModel
|
|||||||
return ["error" => "New folder name already exists."];
|
return ["error" => "New folder name already exists."];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rename($oldReal, $newPath)) {
|
if (!@rename($oldReal, $newPath)) {
|
||||||
return ["error" => "Failed to rename folder."];
|
return ["error" => "Failed to rename folder."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update metadata filenames (prefix-rename)
|
// Update metadata filenames (prefix-rename)
|
||||||
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldRel);
|
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldRel);
|
||||||
$newPrefix = str_replace(['/', '\\', ' '], '-', implode('/', $newParts));
|
$newPrefix = str_replace(['/', '\\', ' '], '-', $newRel);
|
||||||
$globPat = META_DIR . $oldPrefix . '*_metadata.json';
|
$globPat = META_DIR . $oldPrefix . '*_metadata.json';
|
||||||
$metadataFiles = glob($globPat) ?: [];
|
$metadataFiles = glob($globPat) ?: [];
|
||||||
|
|
||||||
@@ -191,6 +324,9 @@ class FolderModel
|
|||||||
@rename($oldMetaFile, $newMeta);
|
@rename($oldMetaFile, $newMeta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update ownership mapping for the entire subtree.
|
||||||
|
self::renameOwnersForTree($oldRel, $newRel);
|
||||||
|
|
||||||
return ["success" => true];
|
return ["success" => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,8 +353,9 @@ 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.)
|
||||||
*/
|
*/
|
||||||
public static function getFolderList(): array
|
public static function getFolderList($ignoredParent = null, ?string $username = null, array $perms = []): array
|
||||||
{
|
{
|
||||||
$baseDir = realpath(UPLOAD_DIR);
|
$baseDir = realpath(UPLOAD_DIR);
|
||||||
if ($baseDir === false) {
|
if ($baseDir === false) {
|
||||||
@@ -256,6 +393,12 @@ class FolderModel
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($username !== null) {
|
||||||
|
$folderInfoList = array_values(array_filter(
|
||||||
|
$folderInfoList,
|
||||||
|
fn($row) => ACL::canRead($username, $perms, $row['folder'])
|
||||||
|
));
|
||||||
|
}
|
||||||
return $folderInfoList;
|
return $folderInfoList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,63 +81,94 @@ class userModel
|
|||||||
* Remove a user and update encrypted userPermissions.json.
|
* Remove a user and update encrypted userPermissions.json.
|
||||||
*/
|
*/
|
||||||
public static function removeUser($usernameToRemove)
|
public static function removeUser($usernameToRemove)
|
||||||
{
|
{
|
||||||
global $encryptionKey;
|
global $encryptionKey;
|
||||||
|
|
||||||
if (!preg_match(REGEX_USER, $usernameToRemove)) {
|
if (!preg_match(REGEX_USER, $usernameToRemove)) {
|
||||||
return ["error" => "Invalid username"];
|
return ["error" => "Invalid username"];
|
||||||
}
|
|
||||||
|
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
|
||||||
if (!file_exists($usersFile)) {
|
|
||||||
return ["error" => "Users file not found"];
|
|
||||||
}
|
|
||||||
|
|
||||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
|
||||||
$newUsers = [];
|
|
||||||
$userFound = false;
|
|
||||||
|
|
||||||
foreach ($existingUsers as $line) {
|
|
||||||
$parts = explode(':', trim($line));
|
|
||||||
if (count($parts) < 3) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($parts[0] === $usernameToRemove) {
|
|
||||||
$userFound = true;
|
|
||||||
continue; // skip
|
|
||||||
}
|
|
||||||
$newUsers[] = $line;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$userFound) {
|
|
||||||
return ["error" => "User not found"];
|
|
||||||
}
|
|
||||||
|
|
||||||
$newContent = $newUsers ? (implode(PHP_EOL, $newUsers) . PHP_EOL) : '';
|
|
||||||
if (file_put_contents($usersFile, $newContent, LOCK_EX) === false) {
|
|
||||||
return ["error" => "Failed to update users file"];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update *encrypted* userPermissions.json consistently
|
|
||||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
|
||||||
if (file_exists($permissionsFile)) {
|
|
||||||
$raw = file_get_contents($permissionsFile);
|
|
||||||
$decrypted = decryptData($raw, $encryptionKey);
|
|
||||||
$permissionsArray = $decrypted !== false
|
|
||||||
? json_decode($decrypted, true)
|
|
||||||
: (json_decode($raw, true) ?: []); // tolerate legacy plaintext
|
|
||||||
|
|
||||||
if (is_array($permissionsArray)) {
|
|
||||||
unset($permissionsArray[strtolower($usernameToRemove)]);
|
|
||||||
$plain = json_encode($permissionsArray, JSON_PRETTY_PRINT);
|
|
||||||
$enc = encryptData($plain, $encryptionKey);
|
|
||||||
file_put_contents($permissionsFile, $enc, LOCK_EX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ["success" => "User removed successfully"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
if (!file_exists($usersFile)) {
|
||||||
|
return ["error" => "Users file not found"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||||
|
$newUsers = [];
|
||||||
|
$userFound = false;
|
||||||
|
|
||||||
|
foreach ($existingUsers as $line) {
|
||||||
|
$parts = explode(':', trim($line));
|
||||||
|
if (count($parts) < 3) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (strcasecmp($parts[0], $usernameToRemove) === 0) {
|
||||||
|
$userFound = true;
|
||||||
|
continue; // skip this user
|
||||||
|
}
|
||||||
|
$newUsers[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$userFound) {
|
||||||
|
return ["error" => "User not found"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$newContent = $newUsers ? (implode(PHP_EOL, $newUsers) . PHP_EOL) : '';
|
||||||
|
if (file_put_contents($usersFile, $newContent, LOCK_EX) === false) {
|
||||||
|
return ["error" => "Failed to update users file"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update encrypted userPermissions.json — remove any key matching case-insensitively
|
||||||
|
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||||
|
if (file_exists($permissionsFile)) {
|
||||||
|
$raw = file_get_contents($permissionsFile);
|
||||||
|
$decrypted = decryptData($raw, $encryptionKey);
|
||||||
|
$permissionsArray = $decrypted !== false
|
||||||
|
? json_decode($decrypted, true)
|
||||||
|
: (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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["success" => "User removed successfully"];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get permissions for current user (or all, if admin).
|
* Get permissions for current user (or all, if admin).
|
||||||
*/
|
*/
|
||||||
@@ -188,7 +219,7 @@ class userModel
|
|||||||
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);
|
||||||
if ($json === false) $json = $encryptedContent; // plain JSON fallback
|
if ($json === false) $json = $encryptedContent; // legacy plaintext
|
||||||
$existingPermissions = json_decode($json, true) ?: [];
|
$existingPermissions = json_decode($json, true) ?: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,22 +240,34 @@ class userModel
|
|||||||
'bypassOwnership','canShare','canZip','viewOwnOnly'
|
'bypassOwnership','canShare','canZip','viewOwnOnly'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Build a map of lowercase->actual key to update existing entries case-insensitively
|
||||||
|
$lcIndex = [];
|
||||||
|
foreach ($existingPermissions as $k => $_) {
|
||||||
|
$lcIndex[strtolower($k)] = $k;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($permissions as $perm) {
|
foreach ($permissions as $perm) {
|
||||||
if (empty($perm['username'])) continue;
|
if (empty($perm['username'])) continue;
|
||||||
$uname = strtolower($perm['username']);
|
|
||||||
$role = $userRoles[$uname] ?? null;
|
$unameOrig = (string)$perm['username']; // preserve original case
|
||||||
|
$unameLc = strtolower($unameOrig);
|
||||||
|
$role = $userRoles[$unameLc] ?? null;
|
||||||
if ($role === "1") continue; // skip admins
|
if ($role === "1") continue; // skip admins
|
||||||
|
|
||||||
$current = $existingPermissions[$uname] ?? [];
|
// Find existing key case-insensitively; otherwise use original case as canonical
|
||||||
|
$storeKey = $lcIndex[$unameLc] ?? $unameOrig;
|
||||||
|
|
||||||
|
$current = $existingPermissions[$storeKey] ?? [];
|
||||||
foreach ($knownKeys as $k) {
|
foreach ($knownKeys as $k) {
|
||||||
if (array_key_exists($k, $perm)) {
|
if (array_key_exists($k, $perm)) {
|
||||||
$current[$k] = (bool)$perm[$k];
|
$current[$k] = (bool)$perm[$k];
|
||||||
} elseif (!isset($current[$k])) {
|
} elseif (!isset($current[$k])) {
|
||||||
// default missing keys to false (preserve existing if set)
|
|
||||||
$current[$k] = false;
|
$current[$k] = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$existingPermissions[$uname] = $current;
|
|
||||||
|
$existingPermissions[$storeKey] = $current;
|
||||||
|
$lcIndex[$unameLc] = $storeKey; // keep index up to date
|
||||||
}
|
}
|
||||||
|
|
||||||
$plain = json_encode($existingPermissions, JSON_PRETTY_PRINT);
|
$plain = json_encode($existingPermissions, JSON_PRETTY_PRINT);
|
||||||
|
|||||||
168
src/openapi/Components.php
Normal file
168
src/openapi/Components.php
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
// src/openapi/Components.php
|
||||||
|
|
||||||
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Info(
|
||||||
|
* title="FileRise API",
|
||||||
|
* version="1.5.2"
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Server(
|
||||||
|
* url="/",
|
||||||
|
* description="Same-origin server"
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Tag(
|
||||||
|
* name="Admin",
|
||||||
|
* description="Admin endpoints"
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Components(
|
||||||
|
* @OA\SecurityScheme(
|
||||||
|
* securityScheme="cookieAuth",
|
||||||
|
* type="apiKey",
|
||||||
|
* in="cookie",
|
||||||
|
* name="PHPSESSID",
|
||||||
|
* description="Session cookie used for authenticated endpoints"
|
||||||
|
* ),
|
||||||
|
* @OA\SecurityScheme(
|
||||||
|
* securityScheme="CsrfHeader",
|
||||||
|
* type="apiKey",
|
||||||
|
* in="header",
|
||||||
|
* name="X-CSRF-Token",
|
||||||
|
* description="CSRF token header required for state-changing requests"
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response="Unauthorized",
|
||||||
|
* description="Unauthorized (no session)",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="error", type="string", example="Unauthorized")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response="Forbidden",
|
||||||
|
* description="Forbidden (not enough privileges)",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="error", type="string", example="Invalid CSRF token.")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="SimpleSuccess",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true)
|
||||||
|
* ),
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="SimpleError",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="error", type="string", example="Something went wrong")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="LoginOptionsPublic",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="disableFormLogin", type="boolean"),
|
||||||
|
* @OA\Property(property="disableBasicAuth", type="boolean"),
|
||||||
|
* @OA\Property(property="disableOIDCLogin", type="boolean")
|
||||||
|
* ),
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="LoginOptionsAdminExtra",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="authBypass", type="boolean", nullable=true),
|
||||||
|
* @OA\Property(property="authHeaderName", type="string", nullable=true, example="X-Remote-User")
|
||||||
|
* ),
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="OIDCConfigPublic",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="providerUrl", type="string", example="https://accounts.example.com"),
|
||||||
|
* @OA\Property(property="redirectUri", type="string", example="https://your.filerise.app/callback")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="AdminGetConfigPublic",
|
||||||
|
* type="object",
|
||||||
|
* required={"header_title","loginOptions","globalOtpauthUrl","enableWebDAV","sharedMaxUploadSize","oidc"},
|
||||||
|
* @OA\Property(property="header_title", type="string", example="FileRise"),
|
||||||
|
* @OA\Property(property="loginOptions", ref="#/components/schemas/LoginOptionsPublic"),
|
||||||
|
* @OA\Property(property="globalOtpauthUrl", type="string"),
|
||||||
|
* @OA\Property(property="enableWebDAV", type="boolean"),
|
||||||
|
* @OA\Property(property="sharedMaxUploadSize", type="integer", format="int64"),
|
||||||
|
* @OA\Property(property="oidc", ref="#/components/schemas/OIDCConfigPublic")
|
||||||
|
* ),
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="AdminGetConfigAdmin",
|
||||||
|
* allOf={
|
||||||
|
* @OA\Schema(ref="#/components/schemas/AdminGetConfigPublic"),
|
||||||
|
* @OA\Schema(
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(
|
||||||
|
* property="loginOptions",
|
||||||
|
* allOf={
|
||||||
|
* @OA\Schema(ref="#/components/schemas/LoginOptionsPublic"),
|
||||||
|
* @OA\Schema(ref="#/components/schemas/LoginOptionsAdminExtra")
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="AdminUpdateConfigRequest",
|
||||||
|
* type="object",
|
||||||
|
* additionalProperties=false,
|
||||||
|
* @OA\Property(property="header_title", type="string", maxLength=100, example="FileRise"),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="loginOptions",
|
||||||
|
* type="object",
|
||||||
|
* additionalProperties=false,
|
||||||
|
* @OA\Property(property="disableFormLogin", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="disableBasicAuth", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="disableOIDCLogin", type="boolean", example=true, description="false = OIDC enabled"),
|
||||||
|
* @OA\Property(property="authBypass", type="boolean", example=false),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="authHeaderName",
|
||||||
|
* type="string",
|
||||||
|
* pattern="^[A-Za-z0-9\\-]+$",
|
||||||
|
* example="X-Remote-User",
|
||||||
|
* description="Letters/numbers/dashes only"
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="globalOtpauthUrl", type="string", example="otpauth://totp/{label}?secret={secret}&issuer=FileRise"),
|
||||||
|
* @OA\Property(property="enableWebDAV", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="sharedMaxUploadSize", type="integer", format="int64", minimum=0, example=52428800),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="oidc",
|
||||||
|
* type="object",
|
||||||
|
* additionalProperties=false,
|
||||||
|
* description="When disableOIDCLogin=false (OIDC enabled), providerUrl, redirectUri, and clientId are required.",
|
||||||
|
* @OA\Property(property="providerUrl", type="string", format="uri", example="https://issuer.example.com"),
|
||||||
|
* @OA\Property(property="clientId", type="string", example="my-client-id"),
|
||||||
|
* @OA\Property(property="clientSecret", type="string", writeOnly=true, example="***"),
|
||||||
|
* @OA\Property(property="redirectUri", type="string", format="uri", example="https://app.example.com/auth/callback")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* request="MoveFilesRequest",
|
||||||
|
* required=true,
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* required={"source","destination","files"},
|
||||||
|
* @OA\Property(property="source", type="string", example="inbox"),
|
||||||
|
* @OA\Property(property="destination", type="string", example="archive"),
|
||||||
|
* @OA\Property(property="files", type="array", @OA\Items(type="string"))
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
final class OpenAPIComponents {}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace FileRise\WebDAV;
|
namespace FileRise\WebDAV;
|
||||||
|
|
||||||
// Bootstrap constants and models
|
//src/webdav/FileRiseDirectory.php
|
||||||
require_once __DIR__ . '/../../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
|
|
||||||
|
require_once __DIR__ . '/../../config/config.php'; // constants + loadUserPermissions()
|
||||||
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 +14,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 +44,185 @@ class FileRiseDirectory implements ICollection, INode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getLastModified(): int {
|
public function getLastModified(): int {
|
||||||
return filemtime($this->path);
|
return @filemtime($this->path) ?: time();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(): void {
|
public function delete(): void {
|
||||||
throw new Forbidden('Cannot delete this node');
|
throw new Forbidden('Cannot delete directories via WebDAV');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setName($name): void {
|
public function setName($name): void {
|
||||||
throw new Forbidden('Renaming not supported');
|
throw new Forbidden('Renaming directories is not supported');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ICollection ────────────────────────────────────
|
// ── ICollection ────────────────────────────────────
|
||||||
|
|
||||||
public function getChildren(): array {
|
public function getChildren(): array {
|
||||||
|
// Determine “folder key” relative to UPLOAD_DIR for ACL checks
|
||||||
|
$folderKey = $this->folderKeyForPath($this->path);
|
||||||
|
|
||||||
|
// Check view permission on *this* directory
|
||||||
|
$canFull = \ACL::canRead($this->user, $this->perms, $folderKey);
|
||||||
|
$canOwn = \ACL::hasGrant($this->user, $folderKey, 'read_own');
|
||||||
|
if (!$this->isAdmin && !$canFull && !$canOwn) {
|
||||||
|
throw new Forbidden('No view access to this folder');
|
||||||
|
}
|
||||||
|
|
||||||
$nodes = [];
|
$nodes = [];
|
||||||
|
$hide = ['trash','profile_pics']; // internal dirs to hide
|
||||||
foreach (new \DirectoryIterator($this->path) as $item) {
|
foreach (new \DirectoryIterator($this->path) as $item) {
|
||||||
if ($item->isDot()) continue;
|
if ($item->isDot()) continue;
|
||||||
|
$name = $item->getFilename();
|
||||||
|
if (in_array(strtolower($name), $hide, true)) continue;
|
||||||
|
|
||||||
$full = $item->getPathname();
|
$full = $item->getPathname();
|
||||||
|
|
||||||
if ($item->isDir()) {
|
if ($item->isDir()) {
|
||||||
$nodes[] = new self($full, $this->user, $this->folderOnly);
|
// Decide if the *child folder* should be visible
|
||||||
} else {
|
$childKey = $this->folderKeyForPath($full);
|
||||||
$nodes[] = new FileRiseFile($full, $this->user);
|
$canChild = $this->isAdmin
|
||||||
|
|| \ACL::canRead($this->user, $this->perms, $childKey)
|
||||||
|
|| \ACL::hasGrant($this->user, $childKey, 'read_own');
|
||||||
|
|
||||||
|
if ($canChild) {
|
||||||
|
$nodes[] = new self($full, $this->user, $this->isAdmin, $this->perms);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File in this directory: only list if full-view OR (own-only AND owner)
|
||||||
|
if ($canFull || $this->fileIsOwnedByUser($folderKey, $name)) {
|
||||||
|
$nodes[] = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Apply folder‑only at the top level
|
|
||||||
if (
|
|
||||||
$this->folderOnly
|
|
||||||
&& realpath($this->path) === realpath(rtrim(UPLOAD_DIR,'/\\'))
|
|
||||||
) {
|
|
||||||
$nodes = array_filter($nodes, fn(INode $n)=> $n->getName() === $this->user);
|
|
||||||
}
|
|
||||||
return array_values($nodes);
|
return array_values($nodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function childExists($name): bool {
|
public function childExists($name): bool {
|
||||||
return file_exists($this->path . DIRECTORY_SEPARATOR . $name);
|
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||||
|
if (!file_exists($full)) return false;
|
||||||
|
|
||||||
|
$folderKey = $this->folderKeyForPath($this->path);
|
||||||
|
$isDir = is_dir($full);
|
||||||
|
|
||||||
|
if ($isDir) {
|
||||||
|
$childKey = $this->folderKeyForPath($full);
|
||||||
|
return $this->isAdmin
|
||||||
|
|| \ACL::canRead($this->user, $this->perms, $childKey)
|
||||||
|
|| \ACL::hasGrant($this->user, $childKey, 'read_own');
|
||||||
|
}
|
||||||
|
|
||||||
|
// file
|
||||||
|
$canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey);
|
||||||
|
if ($canFull) return true;
|
||||||
|
|
||||||
|
return \ACL::hasGrant($this->user, $folderKey, 'read_own')
|
||||||
|
&& $this->fileIsOwnedByUser($folderKey, $name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getChild($name): INode {
|
public function getChild($name): INode {
|
||||||
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||||
if (!file_exists($full)) throw new NotFound("Not found: $name");
|
if (!file_exists($full)) throw new NotFound("Not found: $name");
|
||||||
return is_dir($full)
|
|
||||||
? new self($full, $this->user, $this->folderOnly)
|
$folderKey = $this->folderKeyForPath($this->path);
|
||||||
: new FileRiseFile($full, $this->user);
|
if (is_dir($full)) {
|
||||||
|
$childKey = $this->folderKeyForPath($full);
|
||||||
|
$canDir = $this->isAdmin
|
||||||
|
|| \ACL::canRead($this->user, $this->perms, $childKey)
|
||||||
|
|| \ACL::hasGrant($this->user, $childKey, 'read_own');
|
||||||
|
if (!$canDir) throw new Forbidden('No view access to requested folder');
|
||||||
|
return new self($full, $this->user, $this->isAdmin, $this->perms);
|
||||||
|
}
|
||||||
|
|
||||||
|
// file
|
||||||
|
$canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey);
|
||||||
|
if (!$canFull) {
|
||||||
|
if (!\ACL::hasGrant($this->user, $folderKey, 'read_own') || !$this->fileIsOwnedByUser($folderKey, $name)) {
|
||||||
|
throw new Forbidden('No view access to requested file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createFile($name, $data = null): INode {
|
public function createFile($name, $data = null): INode {
|
||||||
|
$folderKey = $this->folderKeyForPath($this->path);
|
||||||
|
|
||||||
|
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
|
||||||
|
throw new Forbidden('No write access to this folder');
|
||||||
|
}
|
||||||
|
if (!empty($this->perms['disableUpload']) && !$this->isAdmin) {
|
||||||
|
throw new Forbidden('Uploads are disabled for your account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write directly to FS, then ensure metadata via FileRiseFile::put()
|
||||||
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||||
$content = is_resource($data) ? stream_get_contents($data) : (string)$data;
|
$content = is_resource($data) ? stream_get_contents($data) : (string)$data;
|
||||||
|
|
||||||
// Compute folder‑key relative to UPLOAD_DIR
|
// Let FileRiseFile handle metadata & overwrite semantics
|
||||||
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1);
|
$fileNode = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
|
||||||
$parts = explode('/', str_replace('\\','/',$rel));
|
$fileNode->put($content);
|
||||||
$filename = array_pop($parts);
|
|
||||||
$folder = empty($parts) ? 'root' : implode('/', $parts);
|
|
||||||
|
|
||||||
FileModel::saveFile($folder, $filename, $content, $this->user);
|
return $fileNode;
|
||||||
return new FileRiseFile($full, $this->user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createDirectory($name): INode {
|
public function createDirectory($name): INode {
|
||||||
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
$parentKey = $this->folderKeyForPath($this->path);
|
||||||
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1);
|
if (!$this->isAdmin && !\ACL::canManage($this->user, $this->perms, $parentKey)) {
|
||||||
|
throw new Forbidden('No permission to create subfolders here');
|
||||||
|
}
|
||||||
|
|
||||||
|
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||||
|
if (!is_dir($full)) {
|
||||||
|
@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,141 @@ 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));
|
|
||||||
$parts = explode(DIRECTORY_SEPARATOR, $rel);
|
if (!$this->isAdmin && !\ACL::canDelete($this->user, $this->perms, $folderKey)) {
|
||||||
$file = array_pop($parts);
|
throw new Forbidden('No delete permission in this folder');
|
||||||
$folder = empty($parts) ? 'root' : $parts[0];
|
}
|
||||||
FileModel::deleteFiles($folder, [$file]);
|
if (!$this->canTouchOwnership($folderKey, $fileName)) {
|
||||||
|
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();
|
||||||
|
|
||||||
|
$exists = is_file($this->path);
|
||||||
|
|
||||||
|
if (!$this->isAdmin) {
|
||||||
|
// uploads disabled blocks both create & overwrite
|
||||||
|
if (!empty($this->perms['disableUpload'])) {
|
||||||
|
throw new Forbidden('Uploads are disabled for your account');
|
||||||
|
}
|
||||||
|
// granular gates
|
||||||
|
if ($exists) {
|
||||||
|
if (!\ACL::canEdit($this->user, $this->perms, $folderKey)) {
|
||||||
|
throw new Forbidden('No edit permission in this folder');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!\ACL::canUpload($this->user, $this->perms, $folderKey)) {
|
||||||
|
throw new Forbidden('No upload permission in this folder');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership on overwrite (unless admin/bypass)
|
||||||
|
$bypass = !empty($this->perms['bypassOwnership']) || $this->isAdmin;
|
||||||
|
if ($exists && !$bypass && !$this->isOwner($folderKey, $fileName)) {
|
||||||
|
throw new Forbidden('You do not own the target file');
|
||||||
|
}
|
||||||
|
|
||||||
|
// write + metadata (unchanged)
|
||||||
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
|
||||||
);
|
);
|
||||||
|
$this->updateMetadata($folderKey, $fileName);
|
||||||
// 2) Update metadata with CurrentUser
|
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
|
||||||
$this->updateMetadata();
|
return null;
|
||||||
|
|
||||||
// 3) Flush to client fast
|
|
||||||
if (function_exists('fastcgi_finish_request')) {
|
|
||||||
fastcgi_finish_request();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null; // no ETag
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSize(): int {
|
public function getSize(): int {
|
||||||
return filesize($this->path);
|
return @filesize($this->path) ?: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getETag(): string {
|
public function getETag(): string {
|
||||||
return '"' . md5($this->getLastModified() . $this->getSize()) . '"';
|
return '"' . md5(($this->getLastModified() ?: 0) . ':' . ($this->getSize() ?: 0)) . '"';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getContentType(): ?string {
|
public function getContentType(): ?string {
|
||||||
return mime_content_type($this->path) ?: null;
|
return @mime_content_type($this->path) ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Metadata helper ───────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function updateMetadata(): void {
|
private function split(): array {
|
||||||
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
$rel = substr($this->path, strlen($base));
|
$rel = ltrim(str_replace('\\','/', substr($this->path, strlen($base))), '/');
|
||||||
$parts = explode(DIRECTORY_SEPARATOR, $rel);
|
$parts = explode('/', $rel);
|
||||||
$fileName = array_pop($parts);
|
$file = array_pop($parts);
|
||||||
$folder = empty($parts) ? 'root' : $parts[0];
|
$folder = empty($parts) ? 'root' : implode('/', $parts);
|
||||||
|
return [$folder, $file];
|
||||||
|
}
|
||||||
|
|
||||||
$metaFile = META_DIR
|
private function metaFile(string $folderKey): string {
|
||||||
. ($folder === 'root'
|
return META_DIR . (
|
||||||
? 'root_metadata.json'
|
$folderKey === 'root'
|
||||||
: str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json');
|
? 'root_metadata.json'
|
||||||
|
: str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$metadata = [];
|
private function loadMeta(string $folderKey): array {
|
||||||
if (file_exists($metaFile)) {
|
$mf = $this->metaFile($folderKey);
|
||||||
$decoded = json_decode(file_get_contents($metaFile), true);
|
if (!is_file($mf)) return [];
|
||||||
if (is_array($decoded)) {
|
$d = json_decode(@file_get_contents($mf), true);
|
||||||
$metadata = $decoded;
|
return is_array($d) ? $d : [];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
private function saveMeta(string $folderKey, array $meta): void {
|
||||||
|
@file_put_contents($this->metaFile($folderKey), json_encode($meta, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isOwner(string $folderKey, string $fileName): bool {
|
||||||
|
$meta = $this->loadMeta($folderKey);
|
||||||
|
return isset($meta[$fileName]['uploader']) &&
|
||||||
|
strcasecmp((string)$meta[$fileName]['uploader'], $this->user) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canTouchOwnership(string $folderKey, string $fileName): bool {
|
||||||
|
if ($this->isAdmin || !empty($this->perms['bypassOwnership'])) return true;
|
||||||
|
return $this->isOwner($folderKey, $fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateMetadata(string $folderKey, string $fileName): void {
|
||||||
|
$meta = $this->loadMeta($folderKey);
|
||||||
$now = date(DATE_TIME_FORMAT);
|
$now = date(DATE_TIME_FORMAT);
|
||||||
$uploaded = $metadata[$fileName]['uploaded'] ?? $now;
|
$uploaded = $meta[$fileName]['uploaded'] ?? $now;
|
||||||
$uploader = CurrentUser::get();
|
$uploader = CurrentUser::get() ?: $this->user;
|
||||||
|
|
||||||
$metadata[$fileName] = [
|
$meta[$fileName] = [
|
||||||
'uploaded' => $uploaded,
|
'uploaded' => $uploaded,
|
||||||
'modified' => $now,
|
'modified' => $now,
|
||||||
'uploader' => $uploader,
|
'uploader' => $uploader,
|
||||||
];
|
];
|
||||||
|
$this->saveMeta($folderKey, $meta);
|
||||||
file_put_contents($metaFile, json_encode($metadata, JSON_PRETTY_PRINT));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user