feat(acl): granular per-folder permissions + stricter gates; WebDAV & UI aligned
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
||||
# 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
|
||||
|
||||
61
README.md
61
README.md
@@ -10,8 +10,14 @@
|
||||
|
||||
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting)
|
||||
|
||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
||||
Upload, organize, and share files or folders through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
|
||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
||||
Upload, organize, and share files or folders through a sleek, responsive web interface.
|
||||
**FileRise** is lightweight yet powerful — your personal cloud drive that you fully control.
|
||||
|
||||
Now featuring **Granular Access Control (ACL)** with per-folder permissions, inheritance, and live admin editing.
|
||||
Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage* on a per-user, per-folder basis — enforced across the UI, API, and WebDAV.
|
||||
|
||||
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
|
||||
|
||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||
|
||||
@@ -26,33 +32,54 @@ Upload, organize, and share files or folders through a sleek web interface. **Fi
|
||||
|
||||
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||
|
||||
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with pause/resumable chunked uploads and shows real-time progress for each file. FileRise will pick up where it left off if your connection drops.
|
||||
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with resumable chunked uploads, pause/resume, and real-time progress. If your connection drops, FileRise resumes automatically.
|
||||
|
||||
- 🗂️ **File Management:** Full set of file/folder operations – move or copy files (via intuitive drag-drop or dialogs), rename items, and delete in batches. You can download selected files as a ZIP archive or extract uploaded ZIP files server-side. Organize content with an interactive folder tree and breadcrumb navigation for quick jumps.
|
||||
- 🗂️ **File Management:** Full suite of operations — move/copy (via drag-drop or dialogs), rename, and batch delete. Download selected files as ZIPs or extract uploaded ZIPs server-side. Organize with an interactive folder tree and breadcrumbs for instant navigation.
|
||||
|
||||
- 🗃️ **Folder Sharing & File Sharing:** Share entire folders via secure, expiring public links. Folder shares can be password-protected, and shared folders support file uploads from outside users with a separate, secure upload mechanism. Folder listings are paginated (10 items/page); file sizes are displayed in MB. Share individual files with one-time or expiring links (optional password protection).
|
||||
- 🗃️ **Folder & File Sharing:** Share folders or individual files with expiring, optionally password-protected links. Shared folders can accept external uploads (if enabled). Listings are paginated (10 items/page) with file sizes shown in MB.
|
||||
|
||||
- 🔐 **Fine-grained Access Control (ACL):** Per-folder grants for **owners**, **read** (view all), **read_own** (own-only visibility), **write** (upload/edit), and **share**.
|
||||
- _Note:_ **write no longer implies read**. Grant **read** if uploaders should see all files; or **read_own** for self-only listings.
|
||||
- Enforced server-side across UI, API, and WebDAV. Includes an admin UI for bulk editing (atomic updates) and safe defaults.
|
||||
- 🔐 **Granular Access Control (ACL):**
|
||||
Per-folder permissions for **owners**, **view**, **view (own)**, **write**, **manage**, **share**, and extended granular capabilities.
|
||||
Each grant controls specific actions across the UI, API, and WebDAV:
|
||||
|
||||
- 🔌 **WebDAV Support (ACL-aware):** Mount FileRise as a network drive **or use it headless from the CLI**. Standard WebDAV ops (upload / download / delete) work in Cyberduck, WinSCP, GNOME Files, Finder, etc., and you can script with `curl`. Listings require **read**; users with **read_own** only see their own files; writes require **write**.
|
||||
- **Manage / Owner:** Full control — can grant/revoke ACLs, rename/create/delete folders, and share subfolders. Implies all other permissions on that folder and its subfolders.
|
||||
- **View (All):** See all files within a folder. Required for sharing folders.
|
||||
- **View (Own):** See only your own uploads (useful for drop zones or limited contributors).
|
||||
- **Write:** General write access — allows editing, renaming, moving, copying, deleting, and extracting files.
|
||||
- **Create:** Allows creating subfolders (now gated by *Manage* or explicit *Create*).
|
||||
- **Upload:** Upload new files (can be given without full write).
|
||||
- **Edit / Rename / Copy / Move / Delete / Extract:** Individually controllable granular file actions.
|
||||
- **Share File / Share Folder:** Create share links; folder shares require full View (All).
|
||||
- **Automatic Propagation:** Enabling **Manage** on a folder applies to all subfolders; deselecting subfolder permissions overrides inheritance in the UI.
|
||||
|
||||
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) and interactive HTML docs (`api.html`) powered by Redoc.
|
||||
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.
|
||||
|
||||
- 📝 **Built-in Editor & Preview:** View images, videos, audio, and PDFs inline with a preview modal. Edit text/code files in your browser with a CodeMirror-based editor with syntax highlighting and line numbers.
|
||||
- 🔌 **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.
|
||||
|
||||
- 🏷️ **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.
|
||||
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) with interactive HTML docs (`api.html`) via Redoc.
|
||||
|
||||
- 🔒 **Auth & SSO:** Username/password login, optional TOTP 2FA, and OIDC (Google/Authentik/Keycloak). Per-user flags like **readOnly**/**disableUpload** still supported, but folder access is governed by the ACL above.
|
||||
- 📝 **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 & 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.
|
||||
|
||||
- 🎨 **Responsive UI (Dark/Light Mode):** Mobile-friendly layout with theme toggle. The interface remembers your preferences (layout, items per page, last visited folder, etc.).
|
||||
- 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak).
|
||||
|
||||
- 🌐 **Internationalization & Localization:** Switch languages via the UI (English, Spanish, French, German). Contributions welcome.
|
||||
- 🗑️ **Trash & Recovery:** Deleted items move to Trash for recovery (default 3-day retention). Admins can restore or purge globally.
|
||||
|
||||
- ⚙️ **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.
|
||||
- 🎨 **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).)
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true);
|
||||
if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
|
||||
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
|
||||
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
|
||||
define('ACL_INHERIT_ON_CREATE', true);
|
||||
|
||||
// Encryption helpers
|
||||
function encryptData($data, $encryptionKey)
|
||||
|
||||
@@ -1,28 +1,5 @@
|
||||
<?php
|
||||
// public/api/admin/acl/getGrants.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/admin/acl/getGrants.php",
|
||||
* summary="Get ACL grants for a user",
|
||||
* tags={"Admin","ACL"},
|
||||
* security={{"cookieAuth":{}}},
|
||||
* @OA\Parameter(name="user", in="query", required=true, @OA\Schema(type="string")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Map of folder → grant flags",
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* required={"grants"},
|
||||
* @OA\Property(property="grants", ref="#/components/schemas/GrantsMap")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=400, description="Invalid user"),
|
||||
* @OA\Response(response=401, description="Unauthorized")
|
||||
* )
|
||||
*/
|
||||
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
@@ -32,7 +9,6 @@ require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Admin only
|
||||
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
|
||||
http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit;
|
||||
}
|
||||
@@ -55,7 +31,7 @@ try {
|
||||
} catch (Throwable $e) { /* ignore */ }
|
||||
|
||||
if (empty($folders)) {
|
||||
$aclPath = META_DIR . 'folder_acl.json';
|
||||
$aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||
if (is_file($aclPath)) {
|
||||
$data = json_decode((string)@file_get_contents($aclPath), true);
|
||||
if (is_array($data['folders'] ?? null)) {
|
||||
@@ -74,29 +50,36 @@ $has = function(array $arr, string $u): bool {
|
||||
|
||||
$out = [];
|
||||
foreach ($folderList as $f) {
|
||||
$rec = ACL::explicit($f); // owners, read, write, share, read_own
|
||||
$rec = ACL::explicitAll($f); // legacy + granular
|
||||
|
||||
$isOwner = $has($rec['owners'], $user);
|
||||
$canUpload = $isOwner || $has($rec['write'], $user);
|
||||
|
||||
// IMPORTANT: full view only if owner or explicit read
|
||||
$isOwner = $has($rec['owners'], $user);
|
||||
$canViewAll = $isOwner || $has($rec['read'], $user);
|
||||
|
||||
// own-only view reflects explicit read_own (we keep it separate even if they have full view)
|
||||
$canViewOwn = $has($rec['read_own'], $user);
|
||||
$canShare = $isOwner || $has($rec['share'], $user);
|
||||
$canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user);
|
||||
|
||||
// Share only if owner or explicit share
|
||||
$canShare = $isOwner || $has($rec['share'], $user);
|
||||
|
||||
if ($canViewAll || $canViewOwn || $canUpload || $isOwner || $canShare) {
|
||||
if ($canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner
|
||||
|| $has($rec['create'],$user) || $has($rec['edit'],$user) || $has($rec['rename'],$user)
|
||||
|| $has($rec['copy'],$user) || $has($rec['move'],$user) || $has($rec['delete'],$user)
|
||||
|| $has($rec['extract'],$user) || $has($rec['share_file'],$user) || $has($rec['share_folder'],$user)) {
|
||||
$out[$f] = [
|
||||
'view' => $canViewAll,
|
||||
'viewOwn' => $canViewOwn,
|
||||
'upload' => $canUpload,
|
||||
'manage' => $isOwner,
|
||||
'share' => $canShare,
|
||||
'view' => $canViewAll,
|
||||
'viewOwn' => $canViewOwn,
|
||||
'write' => $has($rec['write'], $user) || $isOwner,
|
||||
'manage' => $isOwner,
|
||||
'share' => $canShare, // legacy
|
||||
'create' => $isOwner || $has($rec['create'], $user),
|
||||
'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'],$user),
|
||||
'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'],$user),
|
||||
'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'],$user),
|
||||
'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'],$user),
|
||||
'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'],$user),
|
||||
'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'],$user),
|
||||
'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'],$user),
|
||||
'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'],$user),
|
||||
'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'],$user),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);
|
||||
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
<?php
|
||||
// public/api/admin/acl/saveGrants.php
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/admin/acl/saveGrants.php",
|
||||
* summary="Save ACL grants (single-user or batch)",
|
||||
* tags={"Admin","ACL"},
|
||||
* security={{"cookieAuth":{}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* description="Either {user,grants} or {changes:[{user,grants}]}",
|
||||
* @OA\JsonContent(oneOf={
|
||||
* @OA\Schema(ref="#/components/schemas/SaveGrantsSingle"),
|
||||
* @OA\Schema(ref="#/components/schemas/SaveGrantsBatch")
|
||||
* })
|
||||
* ),
|
||||
* @OA\Response(response=200, description="Saved"),
|
||||
* @OA\Response(response=400, description="Invalid payload"),
|
||||
* @OA\Response(response=401, description="Unauthorized"),
|
||||
* @OA\Response(response=403, description="Invalid CSRF")
|
||||
* )
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
@@ -47,22 +25,38 @@ if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------
|
||||
/**
|
||||
* Sanitize a grants map to allowed flags only:
|
||||
* view | viewOwn | upload | manage | share
|
||||
*/
|
||||
function normalize_caps(array $row): array {
|
||||
// booleanize known keys
|
||||
$bool = function($v){ return !empty($v) && $v !== 'false' && $v !== 0; };
|
||||
$k = [
|
||||
'view','viewOwn','upload','manage','share',
|
||||
'create','edit','rename','copy','move','delete','extract',
|
||||
'shareFile','shareFolder','write'
|
||||
];
|
||||
$out = [];
|
||||
foreach ($k as $kk) $out[$kk] = $bool($row[$kk] ?? false);
|
||||
|
||||
// BUSINESS RULES:
|
||||
// A) Share Folder REQUIRES View (all). If shareFolder is true but view is false, force view=true.
|
||||
if ($out['shareFolder'] && !$out['view']) {
|
||||
$out['view'] = true;
|
||||
}
|
||||
|
||||
// B) Share File requires at least View (own). If neither view nor viewOwn set, set viewOwn=true.
|
||||
if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) {
|
||||
$out['viewOwn'] = true;
|
||||
}
|
||||
|
||||
// C) "write" does NOT imply view. It also does not imply granular here; ACL expands legacy write if present.
|
||||
return $out;
|
||||
}
|
||||
|
||||
function sanitize_grants_map(array $grants): array {
|
||||
$allowed = ['view','viewOwn','upload','manage','share'];
|
||||
$out = [];
|
||||
foreach ($grants as $folder => $caps) {
|
||||
if (!is_string($folder)) $folder = (string)$folder;
|
||||
if (!is_array($caps)) $caps = [];
|
||||
$row = [];
|
||||
foreach ($allowed as $k) {
|
||||
$row[$k] = !empty($caps[$k]);
|
||||
}
|
||||
// include folder even if all false (signals "remove all for this user on this folder")
|
||||
$out[$folder] = $row;
|
||||
$out[$folder] = normalize_caps($caps);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
@@ -124,4 +118,4 @@ if (isset($in['changes']) && is_array($in['changes'])) {
|
||||
|
||||
// ---- Fallback --------------------------------------------------------------
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);
|
||||
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
* )
|
||||
*/
|
||||
|
||||
|
||||
declare(strict_types=1);
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
@@ -88,30 +88,50 @@ function loadPermsFor(string $u): array {
|
||||
return [];
|
||||
}
|
||||
|
||||
function isAdminUser(string $u, array $perms): bool {
|
||||
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
|
||||
if (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) return true;
|
||||
$role = $_SESSION['role'] ?? null;
|
||||
if ($role === 'admin' || $role === '1' || $role === 1) return true;
|
||||
if ($u) {
|
||||
$r = userModel::getUserRole($u);
|
||||
if ($r === '1') return true;
|
||||
function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
|
||||
$f = ACL::normalizeFolder($folder);
|
||||
// direct owner
|
||||
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||
// ancestor owner
|
||||
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
|
||||
$pos = strrpos($f, '/');
|
||||
if ($pos === false) break;
|
||||
$f = substr($f, 0, $pos);
|
||||
if ($f === '' || strcasecmp($f, 'root') === 0) break;
|
||||
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* folder-only scope:
|
||||
* - Admins: always in scope
|
||||
* - Non folder-only accounts: always in scope
|
||||
* - Folder-only accounts: in scope iff:
|
||||
* - folder == username OR subpath of username, OR
|
||||
* - user is owner of this folder (or any ancestor)
|
||||
*/
|
||||
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
|
||||
if ($isAdmin) return true;
|
||||
$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
|
||||
if (!$folderOnly) return true;
|
||||
$f = trim($folder);
|
||||
if ($f === '' || strcasecmp($f, 'root') === 0) return false; // non-admin folderOnly: not root
|
||||
return ($f === $u) || (strpos($f, $u . '/') === 0);
|
||||
//$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
|
||||
//if (!$folderOnly) return true;
|
||||
|
||||
$f = ACL::normalizeFolder($folder);
|
||||
if ($f === 'root' || $f === '') {
|
||||
// folder-only users cannot act on root unless they own a subfolder (handled below)
|
||||
return isOwnerOrAncestorOwner($u, $perms, $f);
|
||||
}
|
||||
|
||||
if ($f === $u || str_starts_with($f, $u . '/')) return true;
|
||||
|
||||
// Treat ownership as in-scope
|
||||
return isOwnerOrAncestorOwner($u, $perms, $f);
|
||||
}
|
||||
|
||||
// --- inputs ---
|
||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||
// validate folder path: allow "root" or nested segments matching REGEX_FOLDER_NAME
|
||||
|
||||
// validate folder path
|
||||
if ($folder !== 'root') {
|
||||
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
|
||||
if (empty($parts)) {
|
||||
@@ -129,44 +149,90 @@ if ($folder !== 'root') {
|
||||
$folder = implode('/', $parts);
|
||||
}
|
||||
|
||||
$perms = loadPermsFor($username);
|
||||
$isAdmin = isAdminUser($username, $perms);
|
||||
// --- user + flags ---
|
||||
$perms = loadPermsFor($username);
|
||||
$isAdmin = ACL::isAdmin($perms);
|
||||
$readOnly = !empty($perms['readOnly']);
|
||||
$disableUp = !empty($perms['disableUpload']);
|
||||
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
||||
|
||||
// base permissions via ACL
|
||||
$canRead = $isAdmin || ACL::canRead($username, $perms, $folder);
|
||||
$canWrite = $isAdmin || ACL::canWrite($username, $perms, $folder);
|
||||
$canShare = $isAdmin || ACL::canShare($username, $perms, $folder);
|
||||
// --- ACL base abilities ---
|
||||
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
|
||||
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
|
||||
$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder);
|
||||
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
|
||||
|
||||
// scope + flags
|
||||
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
||||
$readOnly = !empty($perms['readOnly']);
|
||||
$disableUpload = !empty($perms['disableUpload']);
|
||||
$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder);
|
||||
|
||||
$canUpload = $canWrite && !$readOnly && !$disableUpload && $inScope;
|
||||
$canCreateFolder = $canWrite && !$readOnly && $inScope;
|
||||
$canRename = $canWrite && !$readOnly && $inScope;
|
||||
$canDelete = $canWrite && !$readOnly && $inScope;
|
||||
$canMoveIn = $canWrite && !$readOnly && $inScope;
|
||||
// granular base
|
||||
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
|
||||
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
|
||||
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
|
||||
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
|
||||
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
|
||||
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
|
||||
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
|
||||
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
|
||||
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
|
||||
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
|
||||
|
||||
// (optional) owner info if you need it client-side
|
||||
$owner = FolderModel::getOwnerFor($folder);
|
||||
// --- Apply scope + flags to effective UI actions ---
|
||||
$canView = $canViewBase && $inScope; // keep scope for folder-only
|
||||
$canUpload = $gUploadBase && !$readOnly && !$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) {}
|
||||
|
||||
// output
|
||||
echo json_encode([
|
||||
'user' => $username,
|
||||
'folder' => $folder,
|
||||
'isAdmin' => $isAdmin,
|
||||
'flags' => [
|
||||
'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
||||
'user' => $username,
|
||||
'folder' => $folder,
|
||||
'isAdmin' => $isAdmin,
|
||||
'flags' => [
|
||||
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
||||
'readOnly' => $readOnly,
|
||||
'disableUpload' => $disableUpload,
|
||||
'disableUpload' => $disableUp,
|
||||
],
|
||||
'owner' => $owner,
|
||||
'canView' => $canRead,
|
||||
'canUpload' => $canUpload,
|
||||
'canCreate' => $canCreateFolder,
|
||||
'canRename' => $canRename,
|
||||
'canDelete' => $canDelete,
|
||||
'canMoveIn' => $canMoveIn,
|
||||
'canShare' => $canShare,
|
||||
'owner' => $owner,
|
||||
|
||||
// viewing
|
||||
'canView' => $canView,
|
||||
'canViewOwn' => $canViewOwn,
|
||||
|
||||
// write-ish
|
||||
'canUpload' => $canUpload,
|
||||
'canCreate' => $canCreate,
|
||||
'canRename' => $canRename,
|
||||
'canDelete' => $canDelete,
|
||||
'canMoveIn' => $canMoveIn,
|
||||
'canEdit' => $canEdit,
|
||||
'canCopy' => $canCopy,
|
||||
'canExtract' => $canExtract,
|
||||
|
||||
// sharing
|
||||
'canShare' => $canShare, // legacy
|
||||
'canShareFile' => $canShareFileEff,
|
||||
'canShareFolder' => $canShareFoldEff,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -86,26 +86,26 @@ export function getParentFolder(folder) {
|
||||
Breadcrumb Functions
|
||||
----------------------*/
|
||||
|
||||
function setControlEnabled(el, enabled) {
|
||||
if (!el) return;
|
||||
if ('disabled' in el) el.disabled = !enabled;
|
||||
el.classList.toggle('disabled', !enabled);
|
||||
el.setAttribute('aria-disabled', String(!enabled));
|
||||
el.style.pointerEvents = enabled ? '' : 'none';
|
||||
el.style.opacity = enabled ? '' : '0.5';
|
||||
}
|
||||
|
||||
async function applyFolderCapabilities(folder) {
|
||||
try {
|
||||
const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' });
|
||||
if (!res.ok) return;
|
||||
const caps = await res.json();
|
||||
const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' });
|
||||
if (!res.ok) return;
|
||||
const caps = await res.json();
|
||||
window.currentFolderCaps = caps;
|
||||
|
||||
// top buttons
|
||||
const createBtn = document.getElementById('createFolderBtn');
|
||||
const renameBtn = document.getElementById('renameFolderBtn');
|
||||
const deleteBtn = document.getElementById('deleteFolderBtn');
|
||||
const shareBtn = document.getElementById('shareFolderBtn');
|
||||
|
||||
if (createBtn) createBtn.disabled = !caps.canCreate;
|
||||
if (renameBtn) renameBtn.disabled = !caps.canRename || folder === 'root';
|
||||
if (deleteBtn) deleteBtn.disabled = !caps.canDelete || folder === 'root';
|
||||
if (shareBtn) shareBtn.disabled = !caps.canShare || folder === 'root';
|
||||
|
||||
// keep for later if you want context menu to reflect caps
|
||||
window.currentFolderCaps = caps;
|
||||
} catch {}
|
||||
const isRoot = (folder === 'root');
|
||||
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
|
||||
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
|
||||
setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete);
|
||||
setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder);
|
||||
}
|
||||
|
||||
// --- Breadcrumb Delegation Setup ---
|
||||
@@ -146,6 +146,7 @@ function breadcrumbClickHandler(e) {
|
||||
document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
|
||||
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
||||
if (target) target.classList.add("selected");
|
||||
applyFolderCapabilities(window.currentFolder);
|
||||
|
||||
loadFileList(folder);
|
||||
}
|
||||
@@ -824,6 +825,7 @@ function folderManagerContextMenuHandler(e) {
|
||||
const folder = target.getAttribute("data-folder");
|
||||
if (!folder) return;
|
||||
window.currentFolder = folder;
|
||||
applyFolderCapabilities(window.currentFolder);
|
||||
|
||||
// Visual selection
|
||||
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
|
||||
|
||||
@@ -65,6 +65,37 @@ class FileController
|
||||
return [];
|
||||
}
|
||||
|
||||
private static function folderOfPath(string $path): string {
|
||||
// normalize path to folder; files: use dirname, folders: return path
|
||||
$p = trim(str_replace('\\', '/', $path), "/ \t\r\n");
|
||||
if ($p === '' || $p === 'root') return 'root';
|
||||
// If it ends with a slash or is an existing folder path, treat as folder
|
||||
if (substr($p, -1) === '/') $p = rtrim($p, '/');
|
||||
// For files, take the parent folder
|
||||
$dir = dirname($p);
|
||||
return ($dir === '.' || $dir === '') ? 'root' : $dir;
|
||||
}
|
||||
|
||||
private static function ensureSrcDstAllowedForCopy(
|
||||
string $user, array $perms, string $srcPath, string $dstFolder
|
||||
): bool {
|
||||
$srcFolder = ACL::normalizeFolder(self::folderOfPath($srcPath));
|
||||
$dstFolder = ACL::normalizeFolder($dstFolder);
|
||||
// Need to be able to see the source (own or full) and copy into destination
|
||||
return ACL::canReadOwn($user, $perms, $srcFolder)
|
||||
&& ACL::canCopy($user, $perms, $dstFolder);
|
||||
}
|
||||
|
||||
private static function ensureSrcDstAllowedForMove(
|
||||
string $user, array $perms, string $srcPath, string $dstFolder
|
||||
): bool {
|
||||
$srcFolder = ACL::normalizeFolder(self::folderOfPath($srcPath));
|
||||
$dstFolder = ACL::normalizeFolder($dstFolder);
|
||||
// Move removes from source and adds to dest
|
||||
return ACL::canDelete($user, $perms, $srcFolder)
|
||||
&& ACL::canMove($user, $perms, $dstFolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ownership-only enforcement for a set of files in a folder.
|
||||
* Returns null if OK, or an error string.
|
||||
@@ -135,21 +166,26 @@ class FileController
|
||||
// Otherwise, require the specific capability on the target folder
|
||||
$ok = false;
|
||||
switch ($need) {
|
||||
case 'manage':
|
||||
$ok = ACL::canManage($username, $userPermissions, $folder);
|
||||
break;
|
||||
case 'write':
|
||||
$ok = ACL::canWrite($username, $userPermissions, $folder);
|
||||
break;
|
||||
case 'share':
|
||||
$ok = ACL::canShare($username, $userPermissions, $folder);
|
||||
break;
|
||||
case 'read_own':
|
||||
$ok = ACL::canReadOwn($username, $userPermissions, $folder);
|
||||
break;
|
||||
default: // 'read'
|
||||
$ok = ACL::canRead($username, $userPermissions, $folder);
|
||||
}
|
||||
case 'manage': $ok = ACL::canManage($username, $userPermissions, $folder); break;
|
||||
case 'write': $ok = ACL::canWrite($username, $userPermissions, $folder); break; // legacy
|
||||
case 'share': $ok = ACL::canShare($username, $userPermissions, $folder); break; // legacy
|
||||
case 'read_own': $ok = ACL::canReadOwn($username, $userPermissions, $folder); break;
|
||||
// granular:
|
||||
case 'create': $ok = ACL::canCreate($username, $userPermissions, $folder); break;
|
||||
case 'upload': $ok = ACL::canUpload($username, $userPermissions, $folder); break;
|
||||
case 'edit': $ok = ACL::canEdit($username, $userPermissions, $folder); break;
|
||||
case 'rename': $ok = ACL::canRename($username, $userPermissions, $folder); break;
|
||||
case 'copy': $ok = ACL::canCopy($username, $userPermissions, $folder); break;
|
||||
case 'move': $ok = ACL::canMove($username, $userPermissions, $folder); break;
|
||||
case 'delete': $ok = ACL::canDelete($username, $userPermissions, $folder); break;
|
||||
case 'extract': $ok = ACL::canExtract($username, $userPermissions, $folder); break;
|
||||
case 'shareFile':
|
||||
case 'share_file': $ok = ACL::canShareFile($username, $userPermissions, $folder); break;
|
||||
case 'shareFolder':
|
||||
case 'share_folder': $ok = ACL::canShareFolder($username, $userPermissions, $folder); break;
|
||||
default: // 'read'
|
||||
$ok = ACL::canRead($username, $userPermissions, $folder);
|
||||
}
|
||||
|
||||
return $ok ? null : "Forbidden: folder scope violation.";
|
||||
}
|
||||
@@ -209,110 +245,157 @@ class FileController
|
||||
* Actions
|
||||
* ========================= */
|
||||
|
||||
public function copyFiles()
|
||||
{
|
||||
$this->_jsonStart();
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
public function copyFiles()
|
||||
{
|
||||
$this->_jsonStart();
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
|
||||
$data = $this->_readJsonBody();
|
||||
if (
|
||||
!$data
|
||||
|| !isset($data['source'], $data['destination'], $data['files'])
|
||||
|| !is_array($data['files'])
|
||||
) {
|
||||
$this->_jsonOut(["error" => "Invalid request"], 400); return;
|
||||
}
|
||||
|
||||
$sourceFolder = $this->_normalizeFolder($data['source']);
|
||||
$destinationFolder = $this->_normalizeFolder($data['destination']);
|
||||
$files = array_values(array_filter(array_map('basename', (array)$data['files'])));
|
||||
|
||||
$data = $this->_readJsonBody();
|
||||
if (!$data || !isset($data['source'], $data['destination'], $data['files']) || !is_array($data['files'])) {
|
||||
$this->_jsonOut(["error" => "Invalid request"], 400); return;
|
||||
|
||||
if (!$this->_validFolder($sourceFolder) || !$this->_validFolder($destinationFolder)) {
|
||||
$this->_jsonOut(["error" => "Invalid folder name(s)."], 400); return;
|
||||
}
|
||||
if (empty($files)) {
|
||||
$this->_jsonOut(["error" => "No files specified."], 400); return;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// --- Permission gates (granular) ------------------------------------
|
||||
// Source: own-only view is enough to copy (we'll enforce ownership below if no full read)
|
||||
$hasSourceView = ACL::canReadOwn($username, $userPermissions, $sourceFolder)
|
||||
|| $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions);
|
||||
if (!$hasSourceView) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no read access to source"], 403); return;
|
||||
}
|
||||
|
||||
// Destination: must have 'copy' capability (or own ancestor)
|
||||
$hasDestCreate = ACL::canCreate($username, $userPermissions, $destinationFolder)
|
||||
|| $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions);
|
||||
if (!$hasDestCreate) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no write access to destination"], 403); return;
|
||||
}
|
||||
|
||||
$sourceFolder = $this->_normalizeFolder($data['source']);
|
||||
$destinationFolder = $this->_normalizeFolder($data['destination']);
|
||||
$files = $data['files'];
|
||||
$needSrcScope = ACL::canRead($username, $userPermissions, $sourceFolder) ? 'read' : 'read_own';
|
||||
|
||||
// Folder-scope checks with the needed capabilities
|
||||
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, $needSrcScope);
|
||||
if ($sv) { $this->_jsonOut(["error" => $sv], 403); return; }
|
||||
|
||||
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'create');
|
||||
if ($dv) { $this->_jsonOut(["error" => $dv], 403); return; }
|
||||
|
||||
// If the user doesn't have full read on source (only read_own), enforce per-file ownership
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
|
||||
if (
|
||||
!$ignoreOwnership
|
||||
&& !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read
|
||||
&& ACL::hasGrant($username, $sourceFolder, 'read_own') // but has own-only
|
||||
) {
|
||||
$ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
|
||||
if ($ownErr) { $this->_jsonOut(["error" => $ownErr], 403); return; }
|
||||
}
|
||||
|
||||
if (!$this->_validFolder($sourceFolder) || !$this->_validFolder($destinationFolder)) {
|
||||
$this->_jsonOut(["error" => "Invalid folder name(s)."], 400); return;
|
||||
}
|
||||
// Account flags: copy writes new objects into destination
|
||||
if (!empty($userPermissions['readOnly'])) {
|
||||
$this->_jsonOut(["error" => "Account is read-only."], 403); return;
|
||||
}
|
||||
if (!empty($userPermissions['disableUpload'])) {
|
||||
$this->_jsonOut(["error" => "Uploads are disabled for your account."], 403); return;
|
||||
}
|
||||
|
||||
// --- Do the copy ----------------------------------------------------
|
||||
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
|
||||
$this->_jsonOut($result);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::copyFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while copying files.'], 500);
|
||||
} finally {
|
||||
$this->_jsonEnd();
|
||||
}
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
public function deleteFiles()
|
||||
{
|
||||
$this->_jsonStart();
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
|
||||
// Gate: need read on source (or ancestor-owner) and write on destination (or ancestor-owner)
|
||||
if (!(ACL::canRead($username, $userPermissions, $sourceFolder) || $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no read access to source"], 403); return;
|
||||
}
|
||||
if (!(ACL::canWrite($username, $userPermissions, $destinationFolder) || $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
|
||||
}
|
||||
$data = $this->_readJsonBody();
|
||||
if (!is_array($data) || !isset($data['files']) || !is_array($data['files'])) {
|
||||
$this->_jsonOut(["error" => "No file names provided"], 400); return;
|
||||
}
|
||||
|
||||
// Folder-scope checks with the needed capabilities
|
||||
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'read');
|
||||
if ($sv) { $this->_jsonOut(["error"=>$sv], 403); return; }
|
||||
// sanitize/normalize the list (empty names filtered out)
|
||||
$files = array_values(array_filter(array_map('strval', $data['files']), fn($s) => $s !== ''));
|
||||
if (!$files) {
|
||||
$this->_jsonOut(["error" => "No file names provided"], 400); return;
|
||||
}
|
||||
|
||||
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'write');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
$folder = $this->_normalizeFolder($data['folder'] ?? 'root');
|
||||
if (!$this->_validFolder($folder)) {
|
||||
$this->_jsonOut(["error" => "Invalid folder name."], 400); return;
|
||||
}
|
||||
|
||||
// If the user doesn't have full read on source (only read_own), enforce per-file ownership
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
if (
|
||||
!$ignoreOwnership
|
||||
&& !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read
|
||||
&& ACL::hasGrant($username, $sourceFolder, 'read_own') // but has own-only
|
||||
) {
|
||||
$ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
|
||||
if ($ownErr) { $this->_jsonOut(["error"=>$ownErr], 403); return; }
|
||||
}
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
|
||||
$this->_jsonOut($result);
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::copyFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while copying files.'], 500);
|
||||
} finally { $this->_jsonEnd(); }
|
||||
}
|
||||
// --- Permission gates (granular) ------------------------------------
|
||||
// Need delete on folder (or ancestor-owner)
|
||||
$hasDelete = ACL::canDelete($username, $userPermissions, $folder)
|
||||
|| $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
|
||||
if (!$hasDelete) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no delete permission"], 403); return;
|
||||
}
|
||||
|
||||
public function deleteFiles()
|
||||
{
|
||||
$this->_jsonStart();
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
// --- Folder-scope check (granular) ----------------------------------
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'delete');
|
||||
if ($dv) { $this->_jsonOut(["error" => $dv], 403); return; }
|
||||
|
||||
$data = $this->_readJsonBody();
|
||||
if (!isset($data['files']) || !is_array($data['files'])) {
|
||||
$this->_jsonOut(["error" => "No file names provided"], 400); return;
|
||||
}
|
||||
// --- Ownership enforcement when user only has viewOwn ----------------
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
$isFolderOwner = ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
|
||||
|
||||
$folder = $this->_normalizeFolder($data['folder'] ?? 'root');
|
||||
if (!$this->_validFolder($folder)) {
|
||||
$this->_jsonOut(["error" => "Invalid folder name."], 400); return;
|
||||
}
|
||||
// If user is not owner/admin and does NOT have full view, but does have own-only, enforce per-file ownership
|
||||
if (
|
||||
!$ignoreOwnership
|
||||
&& !$isFolderOwner
|
||||
&& !ACL::canRead($username, $userPermissions, $folder) // lacks full read
|
||||
&& ACL::hasGrant($username, $folder, 'read_own') // has own-only
|
||||
) {
|
||||
$ownErr = $this->enforceScopeAndOwnership($folder, $files, $username, $userPermissions);
|
||||
if ($ownErr) { $this->_jsonOut(["error" => $ownErr], 403); return; }
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
// --- Perform delete --------------------------------------------------
|
||||
$result = FileModel::deleteFiles($folder, $files);
|
||||
$this->_jsonOut($result);
|
||||
|
||||
// Need write (or ancestor-owner)
|
||||
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
|
||||
}
|
||||
|
||||
// Folder-scope: write
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
// Ownership enforcement for non-admins who are not folder owners
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
$isFolderOwner = ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
|
||||
|
||||
if (!$ignoreOwnership && !$isFolderOwner) {
|
||||
$violation = $this->enforceScopeAndOwnership($folder, $data['files'], $username, $userPermissions);
|
||||
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
||||
}
|
||||
|
||||
$result = FileModel::deleteFiles($folder, $data['files']);
|
||||
$this->_jsonOut($result);
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::deleteFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while deleting files.'], 500);
|
||||
} finally { $this->_jsonEnd(); }
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::deleteFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while deleting files.'], 500);
|
||||
} finally { $this->_jsonEnd(); }
|
||||
}
|
||||
|
||||
public function moveFiles()
|
||||
{
|
||||
@@ -320,41 +403,59 @@ class FileController
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
|
||||
|
||||
$data = $this->_readJsonBody();
|
||||
if (!$data || !isset($data['source'], $data['destination'], $data['files']) || !is_array($data['files'])) {
|
||||
if (
|
||||
!$data
|
||||
|| !isset($data['source'], $data['destination'], $data['files'])
|
||||
|| !is_array($data['files'])
|
||||
) {
|
||||
$this->_jsonOut(["error" => "Invalid request"], 400); return;
|
||||
}
|
||||
|
||||
|
||||
$sourceFolder = $this->_normalizeFolder($data['source']);
|
||||
$destinationFolder = $this->_normalizeFolder($data['destination']);
|
||||
if (!$this->_validFolder($sourceFolder) || !$this->_validFolder($destinationFolder)) {
|
||||
$this->_jsonOut(["error" => "Invalid folder name(s)."], 400); return;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// Require write on both ends (or ancestor-owner on each end)
|
||||
if (!(ACL::canWrite($username, $userPermissions, $sourceFolder) || $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access to source"], 403); return;
|
||||
|
||||
$files = $data['files'];
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// --- Permission gates (granular) ------------------------------------
|
||||
// Must be able to at least SEE the source and DELETE there
|
||||
$hasSourceView = ACL::canReadOwn($username, $userPermissions, $sourceFolder)
|
||||
|| $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions);
|
||||
if (!$hasSourceView) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no read access to source"], 403); return;
|
||||
}
|
||||
if (!(ACL::canWrite($username, $userPermissions, $destinationFolder) || $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
|
||||
|
||||
$hasSourceDelete = ACL::canDelete($username, $userPermissions, $sourceFolder)
|
||||
|| $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions);
|
||||
if (!$hasSourceDelete) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no delete permission on source"], 403); return;
|
||||
}
|
||||
|
||||
$files = $data['files'];
|
||||
|
||||
// Folder scope: need WRITE on both ends for a move
|
||||
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'write');
|
||||
if ($sv) { $this->_jsonOut(["error"=>$sv], 403); return; }
|
||||
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'write');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
// If the user doesn't have full read on source (only read_own), enforce per-file ownership
|
||||
|
||||
// Destination must allow MOVE
|
||||
$hasDestMove = ACL::canMove($username, $userPermissions, $destinationFolder)
|
||||
|| $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions);
|
||||
if (!$hasDestMove) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no move permission on destination"], 403); return;
|
||||
}
|
||||
|
||||
// --- Folder-scope checks --------------------------------------------
|
||||
// Source needs 'delete' scope; destination needs 'move' scope
|
||||
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'delete');
|
||||
if ($sv) { $this->_jsonOut(["error" => $sv], 403); return; }
|
||||
|
||||
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'move');
|
||||
if ($dv) { $this->_jsonOut(["error" => $dv], 403); return; }
|
||||
|
||||
// --- Ownership enforcement when only viewOwn on source --------------
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
|
||||
|
||||
if (
|
||||
!$ignoreOwnership
|
||||
&& !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read
|
||||
@@ -363,13 +464,17 @@ class FileController
|
||||
$ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
|
||||
if ($ownErr) { $this->_jsonOut(["error"=>$ownErr], 403); return; }
|
||||
}
|
||||
|
||||
|
||||
// --- Perform move ----------------------------------------------------
|
||||
$result = FileModel::moveFiles($sourceFolder, $destinationFolder, $files);
|
||||
$this->_jsonOut($result);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::moveFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while moving files.'], 500);
|
||||
} finally { $this->_jsonEnd(); }
|
||||
} finally {
|
||||
$this->_jsonEnd();
|
||||
}
|
||||
}
|
||||
|
||||
public function renameFile()
|
||||
@@ -378,12 +483,12 @@ class FileController
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
|
||||
|
||||
$data = $this->_readJsonBody();
|
||||
if (!$data || !isset($data['folder'], $data['oldName'], $data['newName'])) {
|
||||
$this->_jsonOut(["error" => "Invalid input"], 400); return;
|
||||
}
|
||||
|
||||
|
||||
$folder = $this->_normalizeFolder($data['folder']);
|
||||
$oldName = basename(trim((string)$data['oldName']));
|
||||
$newName = basename(trim((string)$data['newName']));
|
||||
@@ -391,19 +496,19 @@ class FileController
|
||||
if (!$this->_validFile($oldName) || !$this->_validFile($newName)) {
|
||||
$this->_jsonOut(["error"=>"Invalid file name(s)."], 400); return;
|
||||
}
|
||||
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// Need write (or ancestor-owner)
|
||||
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
|
||||
|
||||
// Need granular rename (or ancestor-owner)
|
||||
if (!(ACL::canRename($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no rename rights"], 403); return;
|
||||
}
|
||||
|
||||
// Folder scope: write
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
|
||||
|
||||
// Folder scope: rename
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'rename');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
|
||||
// Ownership for non-admins when not a folder owner
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
@@ -412,7 +517,7 @@ class FileController
|
||||
$violation = $this->enforceScopeAndOwnership($folder, [$oldName], $username, $userPermissions);
|
||||
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
||||
}
|
||||
|
||||
|
||||
$result = FileModel::renameFile($folder, $oldName, $newName);
|
||||
if (!is_array($result)) throw new RuntimeException('FileModel::renameFile returned non-array');
|
||||
if (isset($result['error'])) { $this->_jsonOut($result, 400); return; }
|
||||
@@ -444,12 +549,12 @@ class FileController
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// Need write (or ancestor-owner)
|
||||
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
|
||||
if (!(ACL::canEdit($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no full write access"], 403); return;
|
||||
}
|
||||
|
||||
// Folder scope: write
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'edit');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
// If overwriting, enforce ownership for non-admins (unless folder owner)
|
||||
@@ -580,7 +685,7 @@ class FileController
|
||||
$perms = $this->loadPerms($username);
|
||||
|
||||
// Optional zip gate by account flag
|
||||
if (!$this->isAdmin($perms) && array_key_exists('canZip', $perms) && !$perms['canZip']) {
|
||||
if (!$this->isAdmin($perms) && !empty($perms['disableZip'])) {
|
||||
$this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403); return;
|
||||
}
|
||||
|
||||
@@ -652,12 +757,12 @@ class FileController
|
||||
$perms = $this->loadPerms($username);
|
||||
|
||||
// must be able to write into target folder (or be ancestor-owner)
|
||||
if (!(ACL::canWrite($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
|
||||
if (!(ACL::canExtract($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no full write access to destination"], 403); return;
|
||||
}
|
||||
|
||||
// Folder scope: write
|
||||
$dv = $this->enforceFolderScope($folder, $username, $perms, 'write');
|
||||
$dv = $this->enforceFolderScope($folder, $username, $perms, 'extract');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
$result = FileModel::extractZipArchive($folder, $data['files']);
|
||||
@@ -785,7 +890,7 @@ class FileController
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// Need share (or ancestor-owner)
|
||||
if (!(ACL::canShare($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
if (!(ACL::canShareFile($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no share access"], 403); return;
|
||||
}
|
||||
|
||||
@@ -936,7 +1041,7 @@ class FileController
|
||||
|
||||
// Need write (or ancestor-owner)
|
||||
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
|
||||
$this->_jsonOut(["error"=>"Forbidden: no full write access"], 403); return;
|
||||
}
|
||||
|
||||
// Folder scope: write
|
||||
@@ -967,49 +1072,78 @@ class FileController
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
|
||||
// convert warnings/notices to exceptions for cleaner error handling
|
||||
set_error_handler(function ($severity, $message, $file, $line) {
|
||||
if (!(error_reporting() & $severity)) return;
|
||||
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
|
||||
|
||||
try {
|
||||
if (empty($_SESSION['username'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!is_dir(META_DIR)) @mkdir(META_DIR, 0775, true);
|
||||
|
||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid folder name.']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir(UPLOAD_DIR)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Uploads directory not found.']);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// --- inputs ---
|
||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||
|
||||
// Validate folder path: allow "root" or nested segments that each match REGEX_FOLDER_NAME
|
||||
if ($folder !== 'root') {
|
||||
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
|
||||
if (empty($parts)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid folder name.']);
|
||||
return;
|
||||
}
|
||||
foreach ($parts as $seg) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid folder name.']);
|
||||
return;
|
||||
}
|
||||
}
|
||||
$folder = implode('/', $parts);
|
||||
}
|
||||
|
||||
// ---- Folder-level view checks (full vs own-only) ----
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$perms = $this->loadPerms($username);
|
||||
|
||||
|
||||
// Full view if read OR ancestor owner
|
||||
$fullView = ACL::canRead($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms);
|
||||
$fullView = ACL::canRead($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms);
|
||||
$ownOnlyGrant = ACL::hasGrant($username, $folder, 'read_own');
|
||||
|
||||
if (!$fullView && !$ownOnlyGrant) {
|
||||
|
||||
// Special-case: keep Root visible but inert if user lacks any visibility there.
|
||||
if ($folder === 'root' && !$fullView && !$ownOnlyGrant) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'folder' => 'root',
|
||||
'files' => [],
|
||||
// Optional hint the UI can use to show a soft message / disable actions:
|
||||
'uiHints' => [
|
||||
'noAccessRoot' => true,
|
||||
'message' => "You don't have access to Root. Select a folder you have access to."
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-root: still enforce 403 if no visibility
|
||||
if ($folder !== 'root' && !$fullView && !$ownOnlyGrant) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden: no view access to this folder.']);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Fetch the list
|
||||
$result = FileModel::getFileList($folder);
|
||||
if ($result === false || $result === null) {
|
||||
@@ -1025,12 +1159,12 @@ class FileController
|
||||
echo json_encode($result);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// ---- Apply own-only filter if user does NOT have full view ----
|
||||
if (!$fullView && $ownOnlyGrant && isset($result['files'])) {
|
||||
$files = $result['files'];
|
||||
|
||||
// If files keyed by filename
|
||||
|
||||
// If files keyed by filename (assoc array)
|
||||
if (is_array($files) && array_keys($files) !== range(0, count($files) - 1)) {
|
||||
$filtered = [];
|
||||
foreach ($files as $name => $meta) {
|
||||
@@ -1040,7 +1174,7 @@ class FileController
|
||||
}
|
||||
$result['files'] = $filtered;
|
||||
}
|
||||
// If files are a numeric array of metadata
|
||||
// If files is a numeric array of metadata items
|
||||
else if (is_array($files)) {
|
||||
$result['files'] = array_values(array_filter(
|
||||
$files,
|
||||
@@ -1050,7 +1184,7 @@ class FileController
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::getFileList error: '.$e->getMessage().' in '.$e->getFile().':'.$e->getLine());
|
||||
@@ -1117,12 +1251,12 @@ class FileController
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// Need write (or ancestor-owner)
|
||||
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
|
||||
if (!(ACL::canCreate($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no full write access"], 403); return;
|
||||
}
|
||||
|
||||
// Folder scope: write
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'create');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
$result = FileModel::createFile($folder, $filename, $username);
|
||||
|
||||
@@ -150,35 +150,65 @@ class FolderController
|
||||
* $need: 'read' | 'write' | 'manage' | 'share' | 'read_own' (default 'read')
|
||||
* Returns null if allowed, or an error string if forbidden.
|
||||
*/
|
||||
private static function enforceFolderScope(string $folder, string $username, array $perms, string $need = 'read'): ?string
|
||||
{
|
||||
// Admins bypass scope
|
||||
if (self::isAdmin($perms)) return null;
|
||||
// In FolderController.php
|
||||
private static function enforceFolderScope(
|
||||
string $folder,
|
||||
string $username,
|
||||
array $perms,
|
||||
string $need = 'read'
|
||||
): ?string {
|
||||
// Admins bypass scope
|
||||
if (self::isAdmin($perms)) return null;
|
||||
|
||||
// Not a folder-only account? no gate here
|
||||
if (!self::isFolderOnly($perms)) return null;
|
||||
// If this account isn't folder-scoped, don't gate here
|
||||
if (!self::isFolderOnly($perms)) return null;
|
||||
|
||||
$folder = ACL::normalizeFolder($folder);
|
||||
$folder = ACL::normalizeFolder($folder);
|
||||
|
||||
// If user owns folder or an ancestor, allow
|
||||
$f = $folder;
|
||||
while ($f !== '' && strtolower($f) !== 'root') {
|
||||
if (ACL::isOwner($username, $perms, $f)) return null;
|
||||
$pos = strrpos($f, '/');
|
||||
$f = ($pos === false) ? '' : substr($f, 0, $pos);
|
||||
}
|
||||
|
||||
// Otherwise, require specific capability on the target folder
|
||||
switch ($need) {
|
||||
case 'manage': $ok = ACL::canManage($username, $perms, $folder); break;
|
||||
case 'write': $ok = ACL::canWrite($username, $perms, $folder); break;
|
||||
case 'share': $ok = ACL::canShare($username, $perms, $folder); break;
|
||||
case 'read_own': $ok = ACL::canReadOwn($username, $perms, $folder);break;
|
||||
default: $ok = ACL::canRead($username, $perms, $folder);
|
||||
}
|
||||
return $ok ? null : "Forbidden: folder scope violation.";
|
||||
// If user owns folder or an ancestor, allow
|
||||
$f = $folder;
|
||||
while ($f !== '' && strtolower($f) !== 'root') {
|
||||
if (ACL::isOwner($username, $perms, $f)) return null;
|
||||
$pos = strrpos($f, '/');
|
||||
$f = ($pos === false) ? '' : substr($f, 0, $pos);
|
||||
}
|
||||
|
||||
// Normalize aliases so callers can pass either camelCase or snake_case
|
||||
switch ($need) {
|
||||
case 'manage': $ok = ACL::canManage($username, $perms, $folder); break;
|
||||
|
||||
// legacy:
|
||||
case 'write': $ok = ACL::canWrite($username, $perms, $folder); break;
|
||||
case 'share': $ok = ACL::canShare($username, $perms, $folder); break;
|
||||
|
||||
// read flavors:
|
||||
case 'read_own': $ok = ACL::canReadOwn($username, $perms, $folder); break;
|
||||
case 'read': $ok = ACL::canRead($username, $perms, $folder); break;
|
||||
|
||||
// granular write-ish:
|
||||
case 'create': $ok = ACL::canCreate($username, $perms, $folder); break;
|
||||
case 'upload': $ok = ACL::canUpload($username, $perms, $folder); break;
|
||||
case 'edit': $ok = ACL::canEdit($username, $perms, $folder); break;
|
||||
case 'rename': $ok = ACL::canRename($username, $perms, $folder); break;
|
||||
case 'copy': $ok = ACL::canCopy($username, $perms, $folder); break;
|
||||
case 'move': $ok = ACL::canMove($username, $perms, $folder); break;
|
||||
case 'delete': $ok = ACL::canDelete($username, $perms, $folder); break;
|
||||
case 'extract': $ok = ACL::canExtract($username, $perms, $folder); break;
|
||||
|
||||
// granular share (support both key styles)
|
||||
case 'shareFile':
|
||||
case 'share_file': $ok = ACL::canShareFile($username, $perms, $folder); break;
|
||||
case 'shareFolder':
|
||||
case 'share_folder':$ok = ACL::canShareFolder($username, $perms, $folder); break;
|
||||
|
||||
default:
|
||||
// Default to full read if unknown need was passed
|
||||
$ok = ACL::canRead($username, $perms, $folder);
|
||||
}
|
||||
|
||||
return $ok ? null : "Forbidden: folder scope violation.";
|
||||
}
|
||||
|
||||
/** Returns true if caller can ignore ownership (admin or bypassOwnership/default). */
|
||||
private static function canBypassOwnership(array $perms): bool
|
||||
{
|
||||
@@ -194,49 +224,58 @@ class FolderController
|
||||
|
||||
/* -------------------- API: Create Folder -------------------- */
|
||||
public function createFolder(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
self::requireAuth();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit; }
|
||||
self::requireCsrf();
|
||||
self::requireNotReadOnly();
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
self::requireAuth();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); return; }
|
||||
self::requireCsrf();
|
||||
self::requireNotReadOnly();
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!isset($input['folderName'])) { http_response_code(400); echo json_encode(['error' => 'Folder name not provided.']); exit; }
|
||||
try {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
if (!isset($input['folderName'])) { http_response_code(400); echo json_encode(['error' => 'Folder name not provided.']); return; }
|
||||
|
||||
$folderName = trim((string)$input['folderName']);
|
||||
$parentIn = isset($input['parent']) ? trim((string)$input['parent']) : '';
|
||||
$parentIn = isset($input['parent']) ? trim((string)$input['parent']) : 'root';
|
||||
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||
http_response_code(400); echo json_encode(['error' => 'Invalid folder name.']); exit;
|
||||
http_response_code(400); echo json_encode(['error' => 'Invalid folder name.']); return;
|
||||
}
|
||||
if ($parentIn !== '' && strcasecmp($parentIn, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parentIn)) {
|
||||
http_response_code(400); echo json_encode(['error' => 'Invalid parent folder name.']); exit;
|
||||
http_response_code(400); echo json_encode(['error' => 'Invalid parent folder name.']); return;
|
||||
}
|
||||
|
||||
// Normalize parent to an ACL key
|
||||
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
|
||||
$parent = ($parentIn === '' ? 'root' : $parentIn);
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$perms = self::getPerms();
|
||||
|
||||
// Must be able to write into parent OR be owner (or ancestor owner) of it
|
||||
if (!(ACL::canWrite($username, $perms, $parent) || self::ownsFolderOrAncestor($parent, $username, $perms))) {
|
||||
// Need create on parent OR ownership on parent/ancestor
|
||||
if (!(ACL::canCreateFolder($username, $perms, $parent) || self::ownsFolderOrAncestor($parent, $username, $perms))) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden: no write access to parent folder.']);
|
||||
echo json_encode(['error' => 'Forbidden: manager/owner required on parent.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Folder-scope gate for folder-only accounts (need write on parent)
|
||||
if ($msg = self::enforceFolderScope($parent, $username, $perms, 'write')) {
|
||||
http_response_code(403); echo json_encode(['error' => $msg]); exit;
|
||||
// Folder-scope gate for folder-only accounts (need create on parent)
|
||||
if ($msg = self::enforceFolderScope($parent, $username, $perms, 'manage')) {
|
||||
http_response_code(403); echo json_encode(['error' => $msg]); return;
|
||||
}
|
||||
|
||||
// Model should create folder and seed ACL (owner = creator)
|
||||
$result = FolderModel::createFolder($folderName, $parent, $username);
|
||||
if (empty($result['success'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode($result);
|
||||
return;
|
||||
}
|
||||
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
error_log('createFolder fatal: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Internal error creating folder.']);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- API: Delete Folder -------------------- */
|
||||
public function deleteFolder(): void
|
||||
@@ -307,11 +346,11 @@ class FolderController
|
||||
http_response_code(403); echo json_encode(["error" => $msg]); exit;
|
||||
}
|
||||
// For the new folder path, require write scope (we're "creating" a path)
|
||||
if ($msg = self::enforceFolderScope($newFolder, $username, $perms, 'write')) {
|
||||
if ($msg = self::enforceFolderScope($newFolder, $username, $perms, 'manage')) {
|
||||
http_response_code(403); echo json_encode(["error" => "New path not allowed: " . $msg]); exit;
|
||||
}
|
||||
|
||||
// Strong gates: need manage on old OR ancestor owner; need write on new parent or ancestor owner
|
||||
// Strong gates: need manage on old OR ancestor owner; need manage on new parent OR ancestor owner
|
||||
$canManageOld = ACL::canManage($username, $perms, $oldFolder) || self::ownsFolderOrAncestor($oldFolder, $username, $perms);
|
||||
if (!$canManageOld) {
|
||||
http_response_code(403); echo json_encode(['error' => 'Forbidden: you lack manage rights on the source folder.']); exit;
|
||||
|
||||
@@ -57,7 +57,7 @@ class UploadController {
|
||||
$targetFolder = ACL::normalizeFolder($folderParam);
|
||||
|
||||
// Admins bypass folder canWrite checks
|
||||
if (!$isAdmin && !ACL::canWrite($username, $userPerms, $targetFolder)) {
|
||||
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
|
||||
return;
|
||||
|
||||
294
src/lib/ACL.php
294
src/lib/ACL.php
@@ -6,23 +6,20 @@ require_once PROJECT_ROOT . '/config/config.php';
|
||||
|
||||
class ACL
|
||||
{
|
||||
/** In-memory cache of the ACL file. */
|
||||
private static $cache = null;
|
||||
/** Absolute path to folder_acl.json */
|
||||
private static $path = null;
|
||||
|
||||
/** Capability buckets we store per folder. */
|
||||
private const BUCKETS = ['owners','read','write','share','read_own']; // + read_own (view own only)
|
||||
private const BUCKETS = [
|
||||
'owners','read','write','share','read_own',
|
||||
'create','upload','edit','rename','copy','move','delete','extract',
|
||||
'share_file','share_folder'
|
||||
];
|
||||
|
||||
/** Compute/cache the ACL storage path. */
|
||||
private static function path(): string {
|
||||
if (!self::$path) {
|
||||
self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||
}
|
||||
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||
return self::$path;
|
||||
}
|
||||
|
||||
/** Normalize folder names (slashes + root). */
|
||||
public static function normalizeFolder(string $f): string {
|
||||
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
|
||||
if ($f === '' || $f === 'root') return 'root';
|
||||
@@ -33,23 +30,19 @@ class ACL
|
||||
$user = (string)$user;
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$changed = false;
|
||||
|
||||
foreach ($acl['folders'] as $folder => &$rec) {
|
||||
foreach (self::BUCKETS as $k) {
|
||||
$before = $rec[$k] ?? [];
|
||||
$before = is_array($rec[$k] ?? null) ? $rec[$k] : [];
|
||||
$rec[$k] = array_values(array_filter($before, fn($u) => strcasecmp((string)$u, $user) !== 0));
|
||||
if ($rec[$k] !== $before) $changed = true;
|
||||
}
|
||||
}
|
||||
unset($rec);
|
||||
|
||||
return $changed ? self::save($acl) : true;
|
||||
}
|
||||
|
||||
/** Load ACL fresh from disk, create/heal if needed. */
|
||||
private static function loadFresh(): array {
|
||||
$path = self::path();
|
||||
|
||||
if (!is_file($path)) {
|
||||
@mkdir(dirname($path), 0755, true);
|
||||
$init = [
|
||||
@@ -59,7 +52,17 @@ class ACL
|
||||
'read' => ['admin'],
|
||||
'write' => ['admin'],
|
||||
'share' => ['admin'],
|
||||
'read_own'=> [], // new bucket; empty by default
|
||||
'read_own'=> [],
|
||||
'create' => [],
|
||||
'upload' => [],
|
||||
'edit' => [],
|
||||
'rename' => [],
|
||||
'copy' => [],
|
||||
'move' => [],
|
||||
'delete' => [],
|
||||
'extract' => [],
|
||||
'share_file' => [],
|
||||
'share_folder' => [],
|
||||
],
|
||||
],
|
||||
'groups' => [],
|
||||
@@ -70,12 +73,9 @@ class ACL
|
||||
$json = (string) @file_get_contents($path);
|
||||
$data = json_decode($json, true);
|
||||
if (!is_array($data)) $data = [];
|
||||
|
||||
// Normalize shape
|
||||
$data['folders'] = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
|
||||
$data['groups'] = isset($data['groups']) && is_array($data['groups']) ? $data['groups'] : [];
|
||||
|
||||
// Ensure root exists and has all buckets
|
||||
if (!isset($data['folders']['root']) || !is_array($data['folders']['root'])) {
|
||||
$data['folders']['root'] = [
|
||||
'owners' => ['admin'],
|
||||
@@ -84,16 +84,8 @@ class ACL
|
||||
'share' => ['admin'],
|
||||
'read_own' => [],
|
||||
];
|
||||
} else {
|
||||
foreach (self::BUCKETS as $k) {
|
||||
if (!isset($data['folders']['root'][$k]) || !is_array($data['folders']['root'][$k])) {
|
||||
// sensible defaults: admin in the classic buckets, empty for read_own
|
||||
$data['folders']['root'][$k] = ($k === 'read_own') ? [] : ['admin'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Heal any folder records
|
||||
$healed = false;
|
||||
foreach ($data['folders'] as $folder => &$rec) {
|
||||
if (!is_array($rec)) { $rec = []; $healed = true; }
|
||||
@@ -107,30 +99,22 @@ class ACL
|
||||
unset($rec);
|
||||
|
||||
self::$cache = $data;
|
||||
|
||||
// Persist back if we healed anything
|
||||
if ($healed) {
|
||||
@file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
}
|
||||
|
||||
if ($healed) @file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** Persist ACL to disk and refresh cache. */
|
||||
private static function save(array $acl): bool {
|
||||
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
|
||||
if ($ok) self::$cache = $acl;
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/** Get a bucket list (owners/read/write/share/read_own) for a folder (explicit only). */
|
||||
private static function listFor(string $folder, string $key): array {
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$f = $acl['folders'][$folder] ?? null;
|
||||
return is_array($f[$key] ?? null) ? $f[$key] : [];
|
||||
}
|
||||
|
||||
/** Ensure a folder record exists (giving an initial owner). */
|
||||
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
@@ -141,18 +125,26 @@ class ACL
|
||||
'write' => [$owner],
|
||||
'share' => [$owner],
|
||||
'read_own' => [],
|
||||
'create' => [],
|
||||
'upload' => [],
|
||||
'edit' => [],
|
||||
'rename' => [],
|
||||
'copy' => [],
|
||||
'move' => [],
|
||||
'delete' => [],
|
||||
'extract' => [],
|
||||
'share_file' => [],
|
||||
'share_folder' => [],
|
||||
];
|
||||
self::save($acl);
|
||||
}
|
||||
}
|
||||
|
||||
/** True if this request is admin. */
|
||||
public static function isAdmin(array $perms = []): bool {
|
||||
if (!empty($_SESSION['isAdmin'])) return true;
|
||||
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
|
||||
if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
|
||||
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
|
||||
// Optional: if you configured DEFAULT_ADMIN_USER, treat that username as admin
|
||||
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
|
||||
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
|
||||
return true;
|
||||
@@ -160,24 +152,19 @@ class ACL
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Case-insensitive membership in a capability bucket. $cap: owner|owners|read|write|share|read_own */
|
||||
public static function hasGrant(string $user, string $folder, string $cap): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$capKey = ($cap === 'owner') ? 'owners' : $cap;
|
||||
$arr = self::listFor($folder, $capKey);
|
||||
foreach ($arr as $u) {
|
||||
if (strcasecmp((string)$u, $user) === 0) return true;
|
||||
}
|
||||
foreach ($arr as $u) if (strcasecmp((string)$u, $user) === 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True if user is an explicit owner (or admin). */
|
||||
public static function isOwner(string $user, array $perms, string $folder): bool {
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
|
||||
/** "Manage" in UI == owner. */
|
||||
public static function canManage(string $user, array $perms, string $folder): bool {
|
||||
return self::isOwner($user, $perms, $folder);
|
||||
}
|
||||
@@ -185,19 +172,15 @@ class ACL
|
||||
public static function canRead(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
// IMPORTANT: write no longer implies read
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'read');
|
||||
}
|
||||
|
||||
/** Own-only view = read_own OR (any full view). */
|
||||
public static function canReadOwn(string $user, array $perms, string $folder): bool {
|
||||
// if they can full-view, this is trivially true
|
||||
if (self::canRead($user, $perms, $folder)) return true;
|
||||
return self::hasGrant($user, $folder, 'read_own');
|
||||
}
|
||||
|
||||
/** Upload = write OR owner. No bypassOwnership. */
|
||||
public static function canWrite(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
@@ -205,7 +188,6 @@ class ACL
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
/** Share = share OR owner. No bypassOwnership. */
|
||||
public static function canShare(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
@@ -213,10 +195,7 @@ class ACL
|
||||
|| self::hasGrant($user, $folder, 'share');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return explicit lists for a folder (no inheritance).
|
||||
* Keys: owners, read, write, share, read_own (always arrays).
|
||||
*/
|
||||
// Legacy-only explicit (to avoid breaking existing callers)
|
||||
public static function explicit(string $folder): array {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
@@ -235,10 +214,35 @@ class ACL
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a full explicit record for a folder.
|
||||
* NOTE: preserves existing 'read_own' so older callers don't wipe it.
|
||||
*/
|
||||
// New: full explicit including granular
|
||||
public static function explicitAll(string $folder): array {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$rec = $acl['folders'][$folder] ?? [];
|
||||
$norm = function ($v): array {
|
||||
if (!is_array($v)) return [];
|
||||
$v = array_map('strval', $v);
|
||||
return array_values(array_unique($v));
|
||||
};
|
||||
return [
|
||||
'owners' => $norm($rec['owners'] ?? []),
|
||||
'read' => $norm($rec['read'] ?? []),
|
||||
'write' => $norm($rec['write'] ?? []),
|
||||
'share' => $norm($rec['share'] ?? []),
|
||||
'read_own' => $norm($rec['read_own'] ?? []),
|
||||
'create' => $norm($rec['create'] ?? []),
|
||||
'upload' => $norm($rec['upload'] ?? []),
|
||||
'edit' => $norm($rec['edit'] ?? []),
|
||||
'rename' => $norm($rec['rename'] ?? []),
|
||||
'copy' => $norm($rec['copy'] ?? []),
|
||||
'move' => $norm($rec['move'] ?? []),
|
||||
'delete' => $norm($rec['delete'] ?? []),
|
||||
'extract' => $norm($rec['extract'] ?? []),
|
||||
'share_file' => $norm($rec['share_file'] ?? []),
|
||||
'share_folder' => $norm($rec['share_folder'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
@@ -251,24 +255,23 @@ class ACL
|
||||
'read' => $fmt($read),
|
||||
'write' => $fmt($write),
|
||||
'share' => $fmt($share),
|
||||
// preserve any own-only grants unless caller explicitly manages them elsewhere
|
||||
'read_own' => isset($existing['read_own']) && is_array($existing['read_own'])
|
||||
? array_values(array_unique(array_map('strval', $existing['read_own'])))
|
||||
: [],
|
||||
'create' => isset($existing['create']) && is_array($existing['create']) ? array_values(array_unique(array_map('strval', $existing['create']))) : [],
|
||||
'upload' => isset($existing['upload']) && is_array($existing['upload']) ? array_values(array_unique(array_map('strval', $existing['upload']))) : [],
|
||||
'edit' => isset($existing['edit']) && is_array($existing['edit']) ? array_values(array_unique(array_map('strval', $existing['edit']))) : [],
|
||||
'rename' => isset($existing['rename']) && is_array($existing['rename']) ? array_values(array_unique(array_map('strval', $existing['rename']))) : [],
|
||||
'copy' => isset($existing['copy']) && is_array($existing['copy']) ? array_values(array_unique(array_map('strval', $existing['copy']))) : [],
|
||||
'move' => isset($existing['move']) && is_array($existing['move']) ? array_values(array_unique(array_map('strval', $existing['move']))) : [],
|
||||
'delete' => isset($existing['delete']) && is_array($existing['delete']) ? array_values(array_unique(array_map('strval', $existing['delete']))) : [],
|
||||
'extract' => isset($existing['extract']) && is_array($existing['extract']) ? array_values(array_unique(array_map('strval', $existing['extract']))) : [],
|
||||
'share_file' => isset($existing['share_file']) && is_array($existing['share_file']) ? array_values(array_unique(array_map('strval', $existing['share_file']))) : [],
|
||||
'share_folder' => isset($existing['share_folder']) && is_array($existing['share_folder']) ? array_values(array_unique(array_map('strval', $existing['share_folder']))) : [],
|
||||
];
|
||||
return self::save($acl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic per-user update across many folders.
|
||||
* $grants is like:
|
||||
* [
|
||||
* "folderA" => ["view"=>true, "viewOwn"=>false, "upload"=>true, "manage"=>false, "share"=>false],
|
||||
* "folderB" => ["view"=>false, "viewOwn"=>true, "upload"=>false, "manage"=>false, "share"=>false],
|
||||
* ]
|
||||
* If a folder is INCLUDED with all false, the user is removed from all its buckets.
|
||||
* (If the frontend omits a folder entirely, this method leaves that folder unchanged.)
|
||||
*/
|
||||
public static function applyUserGrantsAtomic(string $user, array $grants): array {
|
||||
$user = (string)$user;
|
||||
$path = self::path();
|
||||
@@ -278,7 +281,6 @@ class ACL
|
||||
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); }
|
||||
|
||||
try {
|
||||
// Read current content
|
||||
$raw = stream_get_contents($fh);
|
||||
if ($raw === false) $raw = '';
|
||||
$acl = json_decode($raw, true);
|
||||
@@ -290,38 +292,59 @@ class ACL
|
||||
|
||||
foreach ($grants as $folder => $caps) {
|
||||
$ff = self::normalizeFolder((string)$folder);
|
||||
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) {
|
||||
$acl['folders'][$ff] = ['owners'=>[], 'read'=>[], 'write'=>[], 'share'=>[], 'read_own'=>[]];
|
||||
}
|
||||
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
|
||||
$rec =& $acl['folders'][$ff];
|
||||
|
||||
// Remove user from all buckets first (idempotent)
|
||||
foreach (self::BUCKETS as $k) {
|
||||
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
|
||||
}
|
||||
foreach (self::BUCKETS as $k) {
|
||||
$arr = is_array($rec[$k]) ? $rec[$k] : [];
|
||||
$rec[$k] = array_values(array_filter(
|
||||
array_map('strval', $rec[$k]),
|
||||
fn($u) => strcasecmp($u, $user) !== 0
|
||||
array_map('strval', $arr),
|
||||
fn($u) => strcasecmp((string)$u, $user) !== 0
|
||||
));
|
||||
}
|
||||
|
||||
$v = !empty($caps['view']); // full view
|
||||
$vo = !empty($caps['viewOwn']); // own-only view
|
||||
$u = !empty($caps['upload']);
|
||||
$m = !empty($caps['manage']);
|
||||
$s = !empty($caps['share']);
|
||||
$v = !empty($caps['view']);
|
||||
$vo = !empty($caps['viewOwn']);
|
||||
$u = !empty($caps['upload']);
|
||||
$m = !empty($caps['manage']);
|
||||
$s = !empty($caps['share']);
|
||||
$w = !empty($caps['write']);
|
||||
|
||||
// Implications
|
||||
if ($m) { $v = true; $u = true; } // owner implies read+write
|
||||
if ($u && !$v && !$vo) $vo = true; // upload needs at least own-only visibility
|
||||
if ($s && !$v) $v = true; // sharing implies full read (can be relaxed if desired)
|
||||
$c = !empty($caps['create']);
|
||||
$ed = !empty($caps['edit']);
|
||||
$rn = !empty($caps['rename']);
|
||||
$cp = !empty($caps['copy']);
|
||||
$mv = !empty($caps['move']);
|
||||
$dl = !empty($caps['delete']);
|
||||
$ex = !empty($caps['extract']);
|
||||
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
|
||||
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
|
||||
|
||||
// Add back per caps
|
||||
if ($m) $rec['owners'][] = $user;
|
||||
if ($v) $rec['read'][] = $user;
|
||||
if ($vo) $rec['read_own'][]= $user;
|
||||
if ($u) $rec['write'][] = $user;
|
||||
if ($s) $rec['share'][] = $user;
|
||||
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $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;
|
||||
|
||||
// De-dup
|
||||
foreach (self::BUCKETS as $k) {
|
||||
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
|
||||
}
|
||||
@@ -330,7 +353,6 @@ class ACL
|
||||
unset($rec);
|
||||
}
|
||||
|
||||
// Write back atomically
|
||||
ftruncate($fh, 0);
|
||||
rewind($fh);
|
||||
$ok = fwrite($fh, json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== false;
|
||||
@@ -344,4 +366,92 @@ class ACL
|
||||
fclose($fh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Granular write family -----------------------------------------------
|
||||
|
||||
public static function canCreate(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'create')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canCreateFolder(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
// Only owners/managers can create subfolders under $folder
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
|
||||
public static function canUpload(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'upload')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canEdit(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'edit')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canRename(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'rename')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canCopy(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'copy')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canMove(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,48 +169,67 @@ class FolderModel
|
||||
* @param string $parent 'root' or nested key (e.g. 'team/reports')
|
||||
* @param string $creator username to set as initial owner (falls back to 'admin')
|
||||
*/
|
||||
public static function createFolder(string $folderName, string $parent = 'root', string $creator = 'admin'): array
|
||||
public static function createFolder(string $folderName, string $parent, string $creator): array
|
||||
{
|
||||
// -------- Normalize incoming values (use ONLY the parameters) --------
|
||||
$folderName = trim((string)$folderName);
|
||||
$parentIn = trim((string)$parent);
|
||||
|
||||
// If the client sent a path in folderName (e.g., "bob/new-sub") and parent is root/empty,
|
||||
// derive parent = "bob" and folderName = "new-sub" so permission checks hit "bob".
|
||||
$normalized = ACL::normalizeFolder($folderName);
|
||||
if ($normalized !== 'root' && strpos($normalized, '/') !== false &&
|
||||
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)) {
|
||||
$parentIn = trim(str_replace('\\', '/', dirname($normalized)), '/');
|
||||
$folderName = basename($normalized);
|
||||
if ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) $parentIn = 'root';
|
||||
}
|
||||
|
||||
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
|
||||
$folderName = trim($folderName);
|
||||
$parent = trim($parent);
|
||||
|
||||
if ($folderName === '' || !preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||
return ['success' => false, 'error' => 'Invalid folder name', 'code' => 400];
|
||||
if ($folderName === '') return ['success'=>false, 'error' => 'Folder name required'];
|
||||
|
||||
// ACL key for new folder
|
||||
$newKey = ($parent === 'root') ? $folderName : ($parent . '/' . $folderName);
|
||||
|
||||
// -------- Compose filesystem paths --------
|
||||
$base = rtrim((string)UPLOAD_DIR, "/\\");
|
||||
$parentRel = ($parent === 'root') ? '' : str_replace('/', DIRECTORY_SEPARATOR, $parent);
|
||||
$parentAbs = $parentRel ? ($base . DIRECTORY_SEPARATOR . $parentRel) : $base;
|
||||
$newAbs = $parentAbs . DIRECTORY_SEPARATOR . $folderName;
|
||||
|
||||
// -------- Exists / sanity checks --------
|
||||
if (!is_dir($parentAbs)) return ['success'=>false, 'error' => 'Parent folder does not exist'];
|
||||
if (is_dir($newAbs)) return ['success'=>false, 'error' => 'Folder already exists'];
|
||||
|
||||
// -------- Create directory --------
|
||||
if (!@mkdir($newAbs, 0775, true)) {
|
||||
$err = error_get_last();
|
||||
return ['success'=>false, 'error' => 'Failed to create folder' . (!empty($err['message']) ? (': '.$err['message']) : '')];
|
||||
}
|
||||
if ($parent !== '' && strcasecmp($parent, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parent)) {
|
||||
return ['success' => false, 'error' => 'Invalid parent folder', 'code' => 400];
|
||||
|
||||
// -------- Seed ACL --------
|
||||
$inherit = defined('ACL_INHERIT_ON_CREATE') && ACL_INHERIT_ON_CREATE;
|
||||
try {
|
||||
if ($inherit) {
|
||||
// Copy parent’s explicit (legacy 5 buckets), add creator to owners
|
||||
$p = ACL::explicit($parent); // owners, read, write, share, read_own
|
||||
$owners = array_values(array_unique(array_map('strval', array_merge($p['owners'], [$creator]))));
|
||||
$read = $p['read'];
|
||||
$write = $p['write'];
|
||||
$share = $p['share'];
|
||||
ACL::upsert($newKey, $owners, $read, $write, $share);
|
||||
} else {
|
||||
// Creator owns the new folder
|
||||
ACL::ensureFolderRecord($newKey, $creator);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Roll back FS if ACL seeding fails
|
||||
@rmdir($newAbs);
|
||||
return ['success'=>false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
|
||||
}
|
||||
|
||||
// Compute ACL key and filesystem path
|
||||
$aclKey = ($parent === '' || strcasecmp($parent, 'root') === 0) ? $folderName : ($parent . '/' . $folderName);
|
||||
|
||||
$base = rtrim(UPLOAD_DIR, '/\\');
|
||||
$path = ($parent === '' || strcasecmp($parent, 'root') === 0)
|
||||
? $base . DIRECTORY_SEPARATOR . $folderName
|
||||
: $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $parent) . DIRECTORY_SEPARATOR . $folderName;
|
||||
|
||||
// Safety: stay inside UPLOAD_DIR
|
||||
$realBase = realpath($base);
|
||||
$realPath = $path; // may not exist yet
|
||||
$parentDir = dirname($path);
|
||||
if (!is_dir($parentDir) && !@mkdir($parentDir, 0775, true)) {
|
||||
return ['success' => false, 'error' => 'Failed to create parent path', 'code' => 500];
|
||||
}
|
||||
|
||||
if (is_dir($path)) {
|
||||
// Idempotent: still ensure ACL record exists
|
||||
ACL::ensureFolderRecord($aclKey, $creator ?: 'admin');
|
||||
return ['success' => true, 'folder' => $aclKey, 'alreadyExists' => true];
|
||||
}
|
||||
|
||||
if (!@mkdir($path, 0775, true)) {
|
||||
return ['success' => false, 'error' => 'Failed to create folder', 'code' => 500];
|
||||
}
|
||||
|
||||
// Seed ACL: owner/read/write/share -> creator; read_own empty
|
||||
ACL::ensureFolderRecord($aclKey, $creator ?: 'admin');
|
||||
|
||||
return ['success' => true, 'folder' => $aclKey];
|
||||
|
||||
return ['success' => true, 'folder' => $newKey];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
namespace FileRise\WebDAV;
|
||||
|
||||
//src/webdav/FileRiseDirectory.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php'; // constants + loadUserPermissions()
|
||||
require_once __DIR__ . '/../../vendor/autoload.php'; // SabreDAV
|
||||
require_once __DIR__ . '/../../src/lib/ACL.php';
|
||||
@@ -166,9 +168,9 @@ class FileRiseDirectory implements ICollection, INode {
|
||||
|
||||
public function createDirectory($name): INode {
|
||||
$parentKey = $this->folderKeyForPath($this->path);
|
||||
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $parentKey)) {
|
||||
throw new Forbidden('No permission to create subfolders here');
|
||||
}
|
||||
if (!$this->isAdmin && !\ACL::canManage($this->user, $this->perms, $parentKey)) {
|
||||
throw new Forbidden('No permission to create subfolders here');
|
||||
}
|
||||
|
||||
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||
if (!is_dir($full)) {
|
||||
|
||||
@@ -38,8 +38,9 @@ class FileRiseFile implements IFile, INode {
|
||||
|
||||
public function delete(): void {
|
||||
[$folderKey, $fileName] = $this->split();
|
||||
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No write access to delete this file');
|
||||
|
||||
if (!$this->isAdmin && !\ACL::canDelete($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No delete permission in this folder');
|
||||
}
|
||||
if (!$this->canTouchOwnership($folderKey, $fileName)) {
|
||||
throw new Forbidden('You do not own this file');
|
||||
@@ -67,34 +68,40 @@ class FileRiseFile implements IFile, INode {
|
||||
|
||||
public function put($data): ?string {
|
||||
[$folderKey, $fileName] = $this->split();
|
||||
|
||||
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No write access to this folder');
|
||||
}
|
||||
if (!empty($this->perms['disableUpload']) && !$this->isAdmin) {
|
||||
throw new Forbidden('Uploads are disabled for your account');
|
||||
}
|
||||
|
||||
// If overwriting existing file, enforce ownership for non-admin unless bypassOwnership
|
||||
|
||||
$exists = is_file($this->path);
|
||||
$bypass = !empty($this->perms['bypassOwnership']);
|
||||
if ($exists && !$this->isAdmin && !$bypass && !$this->isOwner($folderKey, $fileName)) {
|
||||
|
||||
if (!$this->isAdmin) {
|
||||
// uploads disabled blocks both create & overwrite
|
||||
if (!empty($this->perms['disableUpload'])) {
|
||||
throw new Forbidden('Uploads are disabled for your account');
|
||||
}
|
||||
// granular gates
|
||||
if ($exists) {
|
||||
if (!\ACL::canEdit($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No edit permission in this folder');
|
||||
}
|
||||
} else {
|
||||
if (!\ACL::canUpload($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No upload permission in this folder');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ownership on overwrite (unless admin/bypass)
|
||||
$bypass = !empty($this->perms['bypassOwnership']) || $this->isAdmin;
|
||||
if ($exists && !$bypass && !$this->isOwner($folderKey, $fileName)) {
|
||||
throw new Forbidden('You do not own the target file');
|
||||
}
|
||||
|
||||
// Write data
|
||||
|
||||
// write + metadata (unchanged)
|
||||
file_put_contents(
|
||||
$this->path,
|
||||
is_resource($data) ? stream_get_contents($data) : (string)$data
|
||||
);
|
||||
|
||||
// Update metadata (uploader on first write; modified every write)
|
||||
$this->updateMetadata($folderKey, $fileName);
|
||||
|
||||
if (function_exists('fastcgi_finish_request')) {
|
||||
fastcgi_finish_request();
|
||||
}
|
||||
return null; // no ETag
|
||||
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getSize(): int {
|
||||
|
||||
Reference in New Issue
Block a user