security(acl): enforce folder-scope & own-only; fix file list “Select All”; harden ops

This commit is contained in:
Ryan
2025-10-20 02:28:03 -04:00
committed by GitHub
parent 5ffc068041
commit d29900d6ba
5 changed files with 750 additions and 466 deletions

View File

@@ -1,9 +1,46 @@
# Changelog # Changelog
## Changes 10/20/2025 (v1.5.3)
security(acl): enforce folder-scope & own-only; fix file list “Select All”; harden ops
### fileListView.js (v1.5.3)
- Restore master “Select All” checkbox behavior and row highlighting.
- Keep selection working with own-only filtered lists.
- Build preview/thumb URLs via secure API endpoints; avoid direct /uploads.
- Minor UI polish: slider wiring and pagination focus handling.
### FileController.php (v1.5.3)
- Add enforceFolderScope($folder, $user, $perms, $need) and apply across actions.
- Copy/Move: require read on source, write on destination; apply scope on both.
- When user only has read_own, enforce per-file ownership (uploader==user).
- Extract ZIP: require write + scope; consistent 403 messages.
- Save/Rename/Delete/Create: tighten ACL checks; block dangerous extensions; consistent CSRF/Auth handling and error codes.
- Download/ZIP: honor read vs read_own; own-only gates by uploader; safer headers.
### FolderController.php (v1.5.3)
- Align with ACL: enforce folder-scope for non-admins; require owner or bypass for destructive ops.
- Create/Rename/Delete: gate by write on parent/target + ownership when needed.
- Share folder link: require share capability; forbid root sharing for non-admins; validate expiry; optional password.
- Folder listing: return only folders user can fully view or has read_own.
- Shared downloads/uploads: stricter validation, headers, and error handling.
This commits a consistent, least-privilege ACL model (owners/read/write/share/read_own), fixes bulk-select in the UI, and closes scope/ownership gaps across file & folder actions.
feat(dnd): default cards to sidebar on medium screens when no saved layout
- Adds one-time responsive default in loadSidebarOrder() (uses layoutDefaultApplied_v1)
- Preserves existing sidebarOrder/headerOrder and small-screen behavior
- Keeps user changes persistent; no override once a layout exists
---
## Changes 10/19/2025 (v1.5.2) ## Changes 10/19/2025 (v1.5.2)
fix(admin): modal bugs; chore(api): update ReDoc SRI; docs(openapi): add annotations + spec fix(admin): modal bugs; chore(api): update ReDoc SRI; docs(openapi): add annotations + spec
feat(dnd): default cards to sidebar on medium screens when no saved layout
- adminPanel.js - adminPanel.js
- Fix modal open/close reliability and stacking order - Fix modal open/close reliability and stacking order
@@ -23,10 +60,6 @@ feat(dnd): default cards to sidebar on medium screens when no saved layout
common responses, and shared components common responses, and shared components
- Regenerate and commit openapi.json.dist - Regenerate and commit openapi.json.dist
- Adds one-time responsive default in loadSidebarOrder() (uses layoutDefaultApplied_v1)
- Preserves existing sidebarOrder/headerOrder and small-screen behavior
- Keeps user changes persistent; no override once a layout exists
- public/js/adminPanel.js - public/js/adminPanel.js
- public/css/style.css - public/css/style.css
- public/api.php - public/api.php

View File

@@ -4,7 +4,7 @@ import { loadAdminConfigFunc } from './auth.js';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js';
const version = "v1.5.2"; const version = "v1.5.3";
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`; const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
// Translate with fallback: if t(key) just echos the key, use a readable string. // Translate with fallback: if t(key) just echos the key, use a readable string.

View File

@@ -65,6 +65,59 @@ import {
return `/api/file/download.php?${q.toString()}`; return `/api/file/download.php?${q.toString()}`;
} }
// Wire "select all" header checkbox for the current table render
function wireSelectAll(fileListContent) {
// Be flexible about how the header checkbox is identified
const selectAll = fileListContent.querySelector(
'thead input[type="checkbox"].select-all, ' +
'thead .select-all input[type="checkbox"], ' +
'thead input#selectAll, ' +
'thead input#selectAllCheckbox, ' +
'thead input[data-select-all]'
);
if (!selectAll) return;
const getRowCbs = () =>
Array.from(fileListContent.querySelectorAll('tbody .file-checkbox'))
.filter(cb => !cb.disabled);
// Toggle all rows when the header checkbox changes
selectAll.addEventListener('change', () => {
const checked = selectAll.checked;
getRowCbs().forEach(cb => {
cb.checked = checked;
updateRowHighlight(cb);
});
updateFileActionButtons();
// No indeterminate state when explicitly toggled
selectAll.indeterminate = false;
});
// Keep header checkbox state in sync with row selections
const syncHeader = () => {
const cbs = getRowCbs();
const total = cbs.length;
const checked = cbs.filter(cb => cb.checked).length;
if (!total) {
selectAll.checked = false;
selectAll.indeterminate = false;
return;
}
selectAll.checked = checked === total;
selectAll.indeterminate = checked > 0 && checked < total;
};
// Listen for any row checkbox changes to refresh header state
fileListContent.addEventListener('change', (e) => {
if (e.target && e.target.classList.contains('file-checkbox')) {
syncHeader();
}
});
// Initial sync on mount
syncHeader();
}
/* ----------------------------- /* -----------------------------
Helper: robust JSON handling Helper: robust JSON handling
----------------------------- */ ----------------------------- */
@@ -608,6 +661,8 @@ import {
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML; fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
wireSelectAll(fileListContent);
// PATCH each row's preview/thumb to use the secure API URLs // PATCH each row's preview/thumb to use the secure API URLs
if (totalFiles > 0) { if (totalFiles > 0) {
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => { filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
@@ -986,6 +1041,7 @@ import {
// render // render
fileListContent.innerHTML = galleryHTML; fileListContent.innerHTML = galleryHTML;
// pagination buttons for gallery // pagination buttons for gallery
const prevBtn = document.getElementById("prevPageBtn"); const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => { if (prevBtn) prevBtn.addEventListener("click", () => {

File diff suppressed because it is too large Load Diff

View File

@@ -96,6 +96,11 @@ class FolderController
return false; return false;
} }
private static function isFolderOnly(array $perms): bool
{
return !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
}
private static function requireNotReadOnly(): void private static function requireNotReadOnly(): void
{ {
$perms = self::getPerms(); $perms = self::getPerms();
@@ -126,23 +131,52 @@ class FolderController
return round($bytes / 1073741824, 2) . " GB"; return round($bytes / 1073741824, 2) . " GB";
} }
/** Enforce "user folder only" scope for non-admins. Returns error string or null if allowed. */ /** Return true if user is explicit owner of the folder or any of its ancestors (admins also true). */
private static function enforceFolderScope(string $folder, string $username, array $perms): ?string private static function ownsFolderOrAncestor(string $folder, string $username, array $perms): bool
{ {
if (self::isAdmin($perms)) return true;
$folder = ACL::normalizeFolder($folder);
$f = $folder;
while ($f !== '' && strtolower($f) !== 'root') {
if (ACL::isOwner($username, $perms, $f)) return true;
$pos = strrpos($f, '/');
$f = ($pos === false) ? '' : substr($f, 0, $pos);
}
return false;
}
/**
* Enforce per-folder scope for folder-only accounts.
* $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; if (self::isAdmin($perms)) return null;
$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']); // Not a folder-only account? no gate here
if (!$folderOnly) return null; if (!self::isFolderOnly($perms)) return null;
$folder = trim($folder); $folder = ACL::normalizeFolder($folder);
if ($folder === '' || strcasecmp($folder, 'root') === 0) {
return "Forbidden: non-admins may not operate on the root 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);
} }
if ($folder === $username || strpos($folder, $username . '/') === 0) { // Otherwise, require specific capability on the target folder
return null; 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 "Forbidden: folder scope violation."; return $ok ? null : "Forbidden: folder scope violation.";
} }
/** Returns true if caller can ignore ownership (admin or bypassOwnership/default). */ /** Returns true if caller can ignore ownership (admin or bypassOwnership/default). */
@@ -152,62 +186,58 @@ class FolderController
return (bool)($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); return (bool)($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
} }
/** Returns true if caller can share. */ /** ACL-aware folder owner check (explicit). */
private static function canShare(array $perms): bool private static function isFolderOwner(string $folder, string $username, array $perms): bool
{ {
if (self::isAdmin($perms)) return true; return ACL::isOwner($username, $perms, $folder);
return (bool)($perms['canShare'] ?? (defined('DEFAULT_CAN_SHARE') ? DEFAULT_CAN_SHARE : false));
}
/** Check folder ownership via mapping; returns true if $username is the explicit owner. */
private static function isFolderOwner(string $folder, string $username): bool
{
$owner = FolderModel::getOwnerFor($folder);
return is_string($owner) && strcasecmp($owner, $username) === 0;
} }
/* -------------------- API: Create Folder -------------------- */ /* -------------------- API: Create Folder -------------------- */
public function createFolder(): void public function createFolder(): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
self::requireAuth(); self::requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit; } if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit; }
self::requireCsrf(); self::requireCsrf();
self::requireNotReadOnly(); self::requireNotReadOnly();
$input = json_decode(file_get_contents('php://input'), true); $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; } if (!isset($input['folderName'])) { http_response_code(400); echo json_encode(['error' => 'Folder name not provided.']); exit; }
$folderName = trim((string)$input['folderName']); $folderName = trim((string)$input['folderName']);
$parentIn = isset($input['parent']) ? trim((string)$input['parent']) : ''; $parentIn = isset($input['parent']) ? trim((string)$input['parent']) : '';
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { 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.']); exit;
} }
if ($parentIn !== '' && strcasecmp($parentIn, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parentIn)) { 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.']); exit;
} }
// Normalize parent to an ACL key // Normalize parent to an ACL key
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn; $parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$perms = self::getPerms(); $perms = self::getPerms();
// ACL: must be able to WRITE into the parent folder (admins pass) // Must be able to write into parent OR be owner (or ancestor owner) of it
if (!self::isAdmin($perms) && !ACL::canWrite($username, $perms, $parent)) { if (!(ACL::canWrite($username, $perms, $parent) || self::ownsFolderOrAncestor($parent, $username, $perms))) {
http_response_code(403); http_response_code(403);
echo json_encode(['error' => 'Forbidden: no write access to parent folder.']); echo json_encode(['error' => 'Forbidden: no write access to parent folder.']);
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;
}
// Model should create folder and seed ACL (owner = creator)
$result = FolderModel::createFolder($folderName, $parent, $username);
echo json_encode($result);
exit; exit;
} }
// Let the model do the filesystem work AND seed ACL owner
$result = FolderModel::createFolder($folderName, $parent, $username);
echo json_encode($result);
exit;
}
/* -------------------- API: Delete Folder -------------------- */ /* -------------------- API: Delete Folder -------------------- */
public function deleteFolder(): void public function deleteFolder(): void
{ {
@@ -220,15 +250,26 @@ class FolderController
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['folder'])) { http_response_code(400); echo json_encode(["error" => "Folder name not provided."]); exit; } if (!isset($input['folder'])) { http_response_code(400); echo json_encode(["error" => "Folder name not provided."]); exit; }
$folder = trim($input['folder']); $folder = trim((string)$input['folder']);
if (strcasecmp($folder, 'root') === 0) { http_response_code(400); echo json_encode(["error" => "Cannot delete root folder."]); exit; } if (strcasecmp($folder, 'root') === 0) { http_response_code(400); echo json_encode(["error" => "Cannot delete root folder."]); exit; }
if (!preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; } if (!preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; }
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$perms = self::getPerms(); $perms = self::getPerms();
if ($msg = self::enforceFolderScope($folder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => $msg]); exit; } // Folder-scope: need manage (owner) OR explicit manage grant
if (!self::canBypassOwnership($perms) && !self::isFolderOwner($folder, $username)) { if ($msg = self::enforceFolderScope($folder, $username, $perms, 'manage')) {
http_response_code(403); echo json_encode(["error" => $msg]); exit;
}
// Require either manage permission or ancestor ownership (strong gate)
$canManage = ACL::canManage($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms);
if (!$canManage) {
http_response_code(403); echo json_encode(["error" => "Forbidden: you lack manage rights for this folder."]); exit;
}
// If not bypassing ownership, require ownership (direct or ancestor) as an extra safeguard
if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) {
http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the folder owner."]); exit; http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the folder owner."]); exit;
} }
@@ -251,8 +292,8 @@ class FolderController
http_response_code(400); echo json_encode(['error' => 'Required folder names not provided.']); exit; http_response_code(400); echo json_encode(['error' => 'Required folder names not provided.']); exit;
} }
$oldFolder = trim($input['oldFolder']); $oldFolder = trim((string)$input['oldFolder']);
$newFolder = trim($input['newFolder']); $newFolder = trim((string)$input['newFolder']);
if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) { if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) {
http_response_code(400); echo json_encode(['error' => 'Invalid folder name(s).']); exit; http_response_code(400); echo json_encode(['error' => 'Invalid folder name(s).']); exit;
@@ -261,10 +302,23 @@ class FolderController
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$perms = self::getPerms(); $perms = self::getPerms();
if ($msg = self::enforceFolderScope($oldFolder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => $msg]); exit; } // Must be allowed to manage the old folder
if ($msg = self::enforceFolderScope($newFolder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => "New path not allowed: " . $msg]); exit; } if ($msg = self::enforceFolderScope($oldFolder, $username, $perms, 'manage')) {
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')) {
http_response_code(403); echo json_encode(["error" => "New path not allowed: " . $msg]); exit;
}
if (!self::canBypassOwnership($perms) && !self::isFolderOwner($oldFolder, $username)) { // Strong gates: need manage on old OR ancestor owner; need write 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;
}
// If not bypassing ownership, require ownership (direct or ancestor) on the old folder
if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($oldFolder, $username, $perms)) {
http_response_code(403); echo json_encode(['error' => 'Forbidden: you are not the folder owner.']); exit; http_response_code(403); echo json_encode(['error' => 'Forbidden: you are not the folder owner.']); exit;
} }
@@ -275,66 +329,64 @@ class FolderController
/* -------------------- API: Get Folder List -------------------- */ /* -------------------- API: Get Folder List -------------------- */
public function getFolderList(): void public function getFolderList(): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
self::requireAuth(); self::requireAuth();
// Optional "folder" filter (supports nested like "team/reports") // Optional "folder" filter (supports nested like "team/reports")
$parent = $_GET['folder'] ?? null; $parent = $_GET['folder'] ?? null;
if ($parent !== null && $parent !== '' && strcasecmp($parent, 'root') !== 0) { if ($parent !== null && $parent !== '' && strcasecmp($parent, 'root') !== 0) {
$parts = array_filter(explode('/', trim($parent, "/\\ ")), fn($p) => $p !== ''); $parts = array_filter(explode('/', trim($parent, "/\\ ")), fn($p) => $p !== '');
if (empty($parts)) { if (empty($parts)) {
http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]); echo json_encode(["error" => "Invalid folder name."]);
exit; exit;
} }
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
}
$parent = implode('/', $parts);
} }
$parent = implode('/', $parts);
}
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';
$perms = loadUserPermissions($username) ?: []; $perms = self::getPerms();
$isAdmin = self::isAdmin($perms); $isAdmin = self::isAdmin($perms);
// 1) full list from model // 1) Full list from model
$all = FolderModel::getFolderList(); // each row: ["folder","fileCount","metadataFile"] $all = FolderModel::getFolderList(); // each row: ["folder","fileCount","metadataFile"]
if (!is_array($all)) { if (!is_array($all)) { echo json_encode([]); exit; }
echo json_encode([]);
// 2) Filter by view rights
if (!$isAdmin) {
$all = array_values(array_filter($all, function ($row) use ($username, $perms) {
$f = $row['folder'] ?? '';
if ($f === '') return false;
// Full view if canRead OR owns ancestor; otherwise allow if read_own granted
$fullView = ACL::canRead($username, $perms, $f) || FolderController::ownsFolderOrAncestor($f, $username, $perms);
$ownOnly = ACL::hasGrant($username, $f, 'read_own');
return $fullView || $ownOnly;
}));
}
// 3) Optional parent filter (applies to both admin and non-admin)
if ($parent && strcasecmp($parent, 'root') !== 0) {
$pref = $parent . '/';
$all = array_values(array_filter($all, function ($row) use ($parent, $pref) {
$f = $row['folder'] ?? '';
return ($f === $parent) || (strpos($f, $pref) === 0);
}));
}
echo json_encode($all);
exit; exit;
} }
// 2) Admin sees all; others: include folder if user has full view OR own-only view
if (!$isAdmin) {
$all = array_values(array_filter($all, function ($row) use ($username, $perms) {
$f = $row['folder'] ?? '';
if ($f === '') return false;
$fullView = ACL::canRead($username, $perms, $f); // owners|write|read
$ownOnly = ACL::hasGrant($username, $f, 'read_own'); // view-own
return $fullView || $ownOnly;
}));
}
// 3) Optional parent filter (applies to both admin and non-admin)
if ($parent && strcasecmp($parent, 'root') !== 0) {
$pref = $parent . '/';
$all = array_values(array_filter($all, function ($row) use ($parent, $pref) {
$f = $row['folder'] ?? '';
return ($f === $parent) || (strpos($f, $pref) === 0);
}));
}
echo json_encode($all);
exit;
}
/* -------------------- Public Shared Folder HTML -------------------- */ /* -------------------- Public Shared Folder HTML -------------------- */
public function shareFolder(): void public function shareFolder(): void
{ {
@@ -451,10 +503,10 @@ for ($i = $startPage; $i <= $endPage; $i++): ?>
$in = json_decode(file_get_contents("php://input"), true); $in = json_decode(file_get_contents("php://input"), true);
if (!$in || !isset($in['folder'])) { http_response_code(400); echo json_encode(["error" => "Invalid input."]); exit; } if (!$in || !isset($in['folder'])) { http_response_code(400); echo json_encode(["error" => "Invalid input."]); exit; }
$folder = trim($in['folder']); $folder = trim((string)$in['folder']);
$value = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60; $value = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60;
$unit = $in['expirationUnit'] ?? 'minutes'; $unit = $in['expirationUnit'] ?? 'minutes';
$password = $in['password'] ?? ''; $password = (string)($in['password'] ?? '');
$allowUpload = intval($in['allowUpload'] ?? 0); $allowUpload = intval($in['allowUpload'] ?? 0);
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; } if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; }
@@ -463,14 +515,18 @@ for ($i = $startPage; $i <= $endPage; $i++): ?>
$perms = self::getPerms(); $perms = self::getPerms();
$isAdmin = self::isAdmin($perms); $isAdmin = self::isAdmin($perms);
if (!self::canShare($perms)) { http_response_code(403); echo json_encode(["error" => "Sharing is not permitted for your account."]); exit; } // Must have share on this folder OR be ancestor owner
if (!(ACL::canShare($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms))) {
if (!$isAdmin) { http_response_code(403); echo json_encode(["error" => "Sharing is not permitted for your account."]); exit;
if (strcasecmp($folder, 'root') === 0) { http_response_code(403); echo json_encode(["error" => "Only admins may share the root folder."]); exit; }
if ($msg = self::enforceFolderScope($folder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => $msg]); exit; }
} }
if (!self::canBypassOwnership($perms) && !self::isFolderOwner($folder, $username)) { // Folder-scope: need share capability within scope
if ($msg = self::enforceFolderScope($folder, $username, $perms, 'share')) {
http_response_code(403); echo json_encode(["error" => $msg]); exit;
}
// Ownership requirement unless bypassed (allow ancestor owners)
if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) {
http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the owner of this folder."]); exit; http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the owner of this folder."]); exit;
} }