feat(acl): granular per-folder permissions + stricter gates; WebDAV & UI aligned

This commit is contained in:
Ryan
2025-10-22 21:36:04 -04:00
committed by GitHub
parent 0ad7034a7d
commit ee717af750
15 changed files with 1332 additions and 825 deletions

View File

@@ -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

View File

@@ -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 youre 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).)

View File

@@ -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)

View File

@@ -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);

View File

@@ -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}]}']);

View File

@@ -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

View File

@@ -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"));

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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');
}
}

View File

@@ -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 parents 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];
}

View File

@@ -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)) {

View File

@@ -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 {