security(acl): enforce folder-scope & own-only; fix file list “Select All”; harden ops
This commit is contained in:
43
CHANGELOG.md
43
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -65,42 +65,93 @@ class FileController
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ownership-only enforcement for a set of files in a folder.
|
||||||
|
* Returns null if OK, or an error string.
|
||||||
|
*/
|
||||||
private function enforceScopeAndOwnership(string $folder, array $files, string $username, array $userPermissions): ?string {
|
private function enforceScopeAndOwnership(string $folder, array $files, string $username, array $userPermissions): ?string {
|
||||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||||
|
|
||||||
if ($this->isFolderOnly($userPermissions) && !$this->isAdmin($userPermissions)) {
|
|
||||||
$folder = trim($folder);
|
|
||||||
if ($folder !== '' && strtolower($folder) !== 'root') {
|
|
||||||
if ($folder !== $username && strpos($folder, $username . '/') !== 0) {
|
|
||||||
return "Forbidden: folder scope violation.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ignoreOwnership) return null;
|
if ($ignoreOwnership) return null;
|
||||||
|
|
||||||
$metadata = $this->loadFolderMetadata($folder);
|
$metadata = $this->loadFolderMetadata($folder);
|
||||||
foreach ($files as $f) {
|
foreach ($files as $f) {
|
||||||
$name = basename((string)$f);
|
$name = basename((string)$f);
|
||||||
if (!isset($metadata[$name]['uploader']) || strcasecmp($metadata[$name]['uploader'], $username) !== 0) {
|
if (!isset($metadata[$name]['uploader']) || strcasecmp((string)$metadata[$name]['uploader'], $username) !== 0) {
|
||||||
return "Forbidden: you are not the owner of '{$name}'.";
|
return "Forbidden: you are not the owner of '{$name}'.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function enforceFolderScope(string $folder, string $username, array $userPermissions): ?string {
|
/**
|
||||||
|
* True if the user is an owner of the folder or any ancestor folder (admin also true).
|
||||||
|
*/
|
||||||
|
private function ownsFolderOrAncestor(string $folder, string $username, array $userPermissions): bool {
|
||||||
|
if ($this->isAdmin($userPermissions)) return true;
|
||||||
|
$folder = ACL::normalizeFolder($folder);
|
||||||
|
|
||||||
|
// Direct folder first, then walk up ancestors (excluding 'root' sentinel)
|
||||||
|
$f = $folder;
|
||||||
|
while ($f !== '' && strtolower($f) !== 'root') {
|
||||||
|
if (ACL::isOwner($username, $userPermissions, $f)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$pos = strrpos($f, '/');
|
||||||
|
$f = ($pos === false) ? '' : substr($f, 0, $pos);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce per-folder scope when the account is in "folder-only" mode.
|
||||||
|
* $need: 'read' (default) | 'write' | 'manage' | 'share' | 'read_own'
|
||||||
|
* Returns null if allowed, or an error string if forbidden.
|
||||||
|
*/
|
||||||
|
private function enforceFolderScope(
|
||||||
|
string $folder,
|
||||||
|
string $username,
|
||||||
|
array $userPermissions,
|
||||||
|
string $need = 'read'
|
||||||
|
): ?string {
|
||||||
|
// Admins bypass all folder scope checks
|
||||||
if ($this->isAdmin($userPermissions)) return null;
|
if ($this->isAdmin($userPermissions)) return null;
|
||||||
|
|
||||||
|
// If the account isn't restricted to a folder scope, don't gate here
|
||||||
if (!$this->isFolderOnly($userPermissions)) return null;
|
if (!$this->isFolderOnly($userPermissions)) return null;
|
||||||
|
|
||||||
$f = trim($folder);
|
$folder = ACL::normalizeFolder($folder);
|
||||||
|
|
||||||
|
// If user owns this folder (or any ancestor), allow
|
||||||
|
$f = $folder;
|
||||||
while ($f !== '' && strtolower($f) !== 'root') {
|
while ($f !== '' && strtolower($f) !== 'root') {
|
||||||
if (FolderModel::getOwnerFor($f) === $username) return null;
|
if (ACL::isOwner($username, $userPermissions, $f)) {
|
||||||
$pos = strrpos($f, '/');
|
return null;
|
||||||
$f = $pos === false ? '' : substr($f, 0, $pos);
|
|
||||||
}
|
}
|
||||||
return "Forbidden: folder scope violation.";
|
$pos = strrpos($f, '/');
|
||||||
|
$f = ($pos === false) ? '' : substr($f, 0, $pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ok ? null : "Forbidden: folder scope violation.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- small helpers ---
|
// --- small helpers ---
|
||||||
@@ -181,20 +232,33 @@ class FileController
|
|||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
$userPermissions = $this->loadPerms($username);
|
$userPermissions = $this->loadPerms($username);
|
||||||
|
|
||||||
// ACL: require read on source and write on destination (or write on both if your ACL only has canWrite)
|
// Gate: need read on source (or ancestor-owner) and write on destination (or ancestor-owner)
|
||||||
if (!ACL::canRead($username, $userPermissions, $sourceFolder)) {
|
if (!(ACL::canRead($username, $userPermissions, $sourceFolder) || $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions))) {
|
||||||
$this->_jsonOut(["error"=>"Forbidden: no read access to source"], 403); return;
|
$this->_jsonOut(["error"=>"Forbidden: no read access to source"], 403); return;
|
||||||
}
|
}
|
||||||
if (!ACL::canWrite($username, $userPermissions, $destinationFolder)) {
|
if (!(ACL::canWrite($username, $userPermissions, $destinationFolder) || $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions))) {
|
||||||
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
|
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// scope/ownership
|
// Folder-scope checks with the needed capabilities
|
||||||
$violation = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
|
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'read');
|
||||||
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
if ($sv) { $this->_jsonOut(["error"=>$sv], 403); return; }
|
||||||
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions);
|
|
||||||
|
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'write');
|
||||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
|
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
|
||||||
$this->_jsonOut($result);
|
$this->_jsonOut($result);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -223,12 +287,24 @@ class FileController
|
|||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
$userPermissions = $this->loadPerms($username);
|
$userPermissions = $this->loadPerms($username);
|
||||||
|
|
||||||
if (!ACL::canWrite($username, $userPermissions, $folder)) {
|
// 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 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);
|
$violation = $this->enforceScopeAndOwnership($folder, $data['files'], $username, $userPermissions);
|
||||||
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
||||||
|
}
|
||||||
|
|
||||||
$result = FileModel::deleteFiles($folder, $data['files']);
|
$result = FileModel::deleteFiles($folder, $data['files']);
|
||||||
$this->_jsonOut($result);
|
$this->_jsonOut($result);
|
||||||
@@ -259,20 +335,36 @@ class FileController
|
|||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
$userPermissions = $this->loadPerms($username);
|
$userPermissions = $this->loadPerms($username);
|
||||||
|
|
||||||
// Require write on both source and destination to be safe
|
// Require write on both ends (or ancestor-owner on each end)
|
||||||
if (!ACL::canWrite($username, $userPermissions, $sourceFolder)) {
|
if (!(ACL::canWrite($username, $userPermissions, $sourceFolder) || $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions))) {
|
||||||
$this->_jsonOut(["error"=>"Forbidden: no write access to source"], 403); return;
|
$this->_jsonOut(["error"=>"Forbidden: no write access to source"], 403); return;
|
||||||
}
|
}
|
||||||
if (!ACL::canWrite($username, $userPermissions, $destinationFolder)) {
|
if (!(ACL::canWrite($username, $userPermissions, $destinationFolder) || $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions))) {
|
||||||
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
|
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$violation = $this->enforceScopeAndOwnership($sourceFolder, $data['files'], $username, $userPermissions);
|
$files = $data['files'];
|
||||||
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
|
||||||
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions);
|
// 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 ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||||
|
|
||||||
$result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['files']);
|
// 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = FileModel::moveFiles($sourceFolder, $destinationFolder, $files);
|
||||||
$this->_jsonOut($result);
|
$this->_jsonOut($result);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
error_log('FileController::moveFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
error_log('FileController::moveFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||||
@@ -303,12 +395,23 @@ class FileController
|
|||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
$userPermissions = $this->loadPerms($username);
|
$userPermissions = $this->loadPerms($username);
|
||||||
|
|
||||||
if (!ACL::canWrite($username, $userPermissions, $folder)) {
|
// 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 write access"], 403); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Folder scope: write
|
||||||
|
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
|
||||||
|
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));
|
||||||
|
$isFolderOwner = ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
|
||||||
|
if (!$ignoreOwnership && !$isFolderOwner) {
|
||||||
$violation = $this->enforceScopeAndOwnership($folder, [$oldName], $username, $userPermissions);
|
$violation = $this->enforceScopeAndOwnership($folder, [$oldName], $username, $userPermissions);
|
||||||
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
||||||
|
}
|
||||||
|
|
||||||
$result = FileModel::renameFile($folder, $oldName, $newName);
|
$result = FileModel::renameFile($folder, $oldName, $newName);
|
||||||
if (!is_array($result)) throw new RuntimeException('FileModel::renameFile returned non-array');
|
if (!is_array($result)) throw new RuntimeException('FileModel::renameFile returned non-array');
|
||||||
@@ -340,21 +443,30 @@ class FileController
|
|||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
$userPermissions = $this->loadPerms($username);
|
$userPermissions = $this->loadPerms($username);
|
||||||
|
|
||||||
if (!ACL::canWrite($username, $userPermissions, $folder)) {
|
// 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 write access"], 403); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions);
|
// Folder scope: write
|
||||||
|
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
|
||||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||||
|
|
||||||
// If overwriting, enforce ownership for non-admins
|
// If overwriting, enforce ownership for non-admins (unless folder owner)
|
||||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
||||||
$dir = ($folder === 'root') ? $baseDir : $baseDir . DIRECTORY_SEPARATOR . $folder;
|
$dir = ($folder === 'root') ? $baseDir : $baseDir . DIRECTORY_SEPARATOR . $folder;
|
||||||
$path = $dir . DIRECTORY_SEPARATOR . $fileName;
|
$path = $dir . DIRECTORY_SEPARATOR . $fileName;
|
||||||
if (is_file($path)) {
|
if (is_file($path)) {
|
||||||
|
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||||
|
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false))
|
||||||
|
|| ACL::isOwner($username, $userPermissions, $folder)
|
||||||
|
|| $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
|
||||||
|
|
||||||
|
if (!$ignoreOwnership) {
|
||||||
$violation = $this->enforceScopeAndOwnership($folder, [$fileName], $username, $userPermissions);
|
$violation = $this->enforceScopeAndOwnership($folder, [$fileName], $username, $userPermissions);
|
||||||
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$deny = ['php','phtml','phar','php3','php4','php5','php7','php8','pht','shtml','cgi','fcgi'];
|
$deny = ['php','phtml','phar','php3','php4','php5','php7','php8','pht','shtml','cgi','fcgi'];
|
||||||
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||||
@@ -374,7 +486,7 @@ class FileController
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function downloadFile()
|
public function downloadFile()
|
||||||
{
|
{
|
||||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -402,8 +514,11 @@ class FileController
|
|||||||
$ignoreOwnership = $this->isAdmin($perms)
|
$ignoreOwnership = $this->isAdmin($perms)
|
||||||
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||||
|
|
||||||
// Folder-level view grants
|
// Treat ancestor-folder ownership as full view as well
|
||||||
$fullView = $ignoreOwnership || ACL::canRead($username, $perms, $folder);
|
$fullView = $ignoreOwnership
|
||||||
|
|| ACL::canRead($username, $perms, $folder)
|
||||||
|
|| $this->ownsFolderOrAncestor($folder, $username, $perms);
|
||||||
|
|
||||||
$ownGrant = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
|
$ownGrant = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
|
||||||
|
|
||||||
if (!$fullView && !$ownGrant) {
|
if (!$fullView && !$ownGrant) {
|
||||||
@@ -443,10 +558,10 @@ class FileController
|
|||||||
header('Content-Length: ' . filesize($realFilePath));
|
header('Content-Length: ' . filesize($realFilePath));
|
||||||
readfile($realFilePath);
|
readfile($realFilePath);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function downloadZip()
|
public function downloadZip()
|
||||||
{
|
{
|
||||||
$this->_jsonStart();
|
$this->_jsonStart();
|
||||||
try {
|
try {
|
||||||
if (!$this->_checkCsrf()) return;
|
if (!$this->_checkCsrf()) return;
|
||||||
@@ -472,7 +587,10 @@ public function downloadZip()
|
|||||||
$ignoreOwnership = $this->isAdmin($perms)
|
$ignoreOwnership = $this->isAdmin($perms)
|
||||||
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||||
|
|
||||||
$fullView = $ignoreOwnership || ACL::canRead($username, $perms, $folder);
|
// Ancestor-owner counts as full view
|
||||||
|
$fullView = $ignoreOwnership
|
||||||
|
|| ACL::canRead($username, $perms, $folder)
|
||||||
|
|| $this->ownsFolderOrAncestor($folder, $username, $perms);
|
||||||
$ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
|
$ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
|
||||||
|
|
||||||
if (!$fullView && !$ownOnly) {
|
if (!$fullView && !$ownOnly) {
|
||||||
@@ -513,10 +631,10 @@ public function downloadZip()
|
|||||||
error_log('FileController::downloadZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
error_log('FileController::downloadZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||||
$this->_jsonOut(['error' => 'Internal server error while preparing ZIP.'], 500);
|
$this->_jsonOut(['error' => 'Internal server error while preparing ZIP.'], 500);
|
||||||
} finally { $this->_jsonEnd(); }
|
} finally { $this->_jsonEnd(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public function extractZip()
|
public function extractZip()
|
||||||
{
|
{
|
||||||
$this->_jsonStart();
|
$this->_jsonStart();
|
||||||
try {
|
try {
|
||||||
if (!$this->_checkCsrf()) return;
|
if (!$this->_checkCsrf()) return;
|
||||||
@@ -533,12 +651,13 @@ public function extractZip()
|
|||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
$perms = $this->loadPerms($username);
|
$perms = $this->loadPerms($username);
|
||||||
|
|
||||||
// must be able to write into target folder
|
// must be able to write into target folder (or be ancestor-owner)
|
||||||
if (!ACL::canWrite($username, $perms, $folder)) {
|
if (!(ACL::canWrite($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms))) {
|
||||||
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
|
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$dv = $this->enforceFolderScope($folder, $username, $perms);
|
// Folder scope: write
|
||||||
|
$dv = $this->enforceFolderScope($folder, $username, $perms, 'write');
|
||||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||||
|
|
||||||
$result = FileModel::extractZipArchive($folder, $data['files']);
|
$result = FileModel::extractZipArchive($folder, $data['files']);
|
||||||
@@ -547,7 +666,7 @@ public function extractZip()
|
|||||||
error_log('FileController::extractZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
error_log('FileController::extractZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||||
$this->_jsonOut(['error' => 'Internal server error while extracting ZIP.'], 500);
|
$this->_jsonOut(['error' => 'Internal server error while extracting ZIP.'], 500);
|
||||||
} finally { $this->_jsonEnd(); }
|
} finally { $this->_jsonEnd(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public function shareFile()
|
public function shareFile()
|
||||||
{
|
{
|
||||||
@@ -665,15 +784,24 @@ public function extractZip()
|
|||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
$userPermissions = $this->loadPerms($username);
|
$userPermissions = $this->loadPerms($username);
|
||||||
|
|
||||||
if (!ACL::canShare($username, $userPermissions, $folder)) {
|
// Need share (or ancestor-owner)
|
||||||
|
if (!(ACL::canShare($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||||
$this->_jsonOut(["error"=>"Forbidden: no share access"], 403); return;
|
$this->_jsonOut(["error"=>"Forbidden: no share access"], 403); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Folder scope: share
|
||||||
|
$sv = $this->enforceFolderScope($folder, $username, $userPermissions, 'share');
|
||||||
|
if ($sv) { $this->_jsonOut(["error"=>$sv], 403); return; }
|
||||||
|
|
||||||
|
// Ownership unless admin/folder-owner
|
||||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false))
|
||||||
|
|| ACL::isOwner($username, $userPermissions, $folder)
|
||||||
|
|| $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
|
||||||
|
|
||||||
if (!$ignoreOwnership) {
|
if (!$ignoreOwnership) {
|
||||||
$meta = $this->loadFolderMetadata($folder);
|
$meta = $this->loadFolderMetadata($folder);
|
||||||
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
|
if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) {
|
||||||
$this->_jsonOut(["error" => "Forbidden: you are not the owner of this file."], 403); return;
|
$this->_jsonOut(["error" => "Forbidden: you are not the owner of this file."], 403); return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -695,7 +823,7 @@ public function extractZip()
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getTrashItems()
|
public function getTrashItems()
|
||||||
{
|
{
|
||||||
$this->_jsonStart();
|
$this->_jsonStart();
|
||||||
try {
|
try {
|
||||||
if (!$this->_requireAuth()) return;
|
if (!$this->_requireAuth()) return;
|
||||||
@@ -708,10 +836,10 @@ public function extractZip()
|
|||||||
error_log('FileController::getTrashItems error: '.$e->getMessage());
|
error_log('FileController::getTrashItems error: '.$e->getMessage());
|
||||||
$this->_jsonOut(['error' => 'Internal server error while fetching trash.'], 500);
|
$this->_jsonOut(['error' => 'Internal server error while fetching trash.'], 500);
|
||||||
} finally { $this->_jsonEnd(); }
|
} finally { $this->_jsonEnd(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public function restoreFiles()
|
public function restoreFiles()
|
||||||
{
|
{
|
||||||
$this->_jsonStart();
|
$this->_jsonStart();
|
||||||
try {
|
try {
|
||||||
if (!$this->_checkCsrf()) return;
|
if (!$this->_checkCsrf()) return;
|
||||||
@@ -729,10 +857,10 @@ public function restoreFiles()
|
|||||||
error_log('FileController::restoreFiles error: '.$e->getMessage());
|
error_log('FileController::restoreFiles error: '.$e->getMessage());
|
||||||
$this->_jsonOut(['error' => 'Internal server error while restoring files.'], 500);
|
$this->_jsonOut(['error' => 'Internal server error while restoring files.'], 500);
|
||||||
} finally { $this->_jsonEnd(); }
|
} finally { $this->_jsonEnd(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteTrashFiles()
|
public function deleteTrashFiles()
|
||||||
{
|
{
|
||||||
$this->_jsonStart();
|
$this->_jsonStart();
|
||||||
try {
|
try {
|
||||||
if (!$this->_checkCsrf()) return;
|
if (!$this->_checkCsrf()) return;
|
||||||
@@ -774,7 +902,7 @@ public function deleteTrashFiles()
|
|||||||
error_log('FileController::deleteTrashFiles error: '.$e->getMessage());
|
error_log('FileController::deleteTrashFiles error: '.$e->getMessage());
|
||||||
$this->_jsonOut(['error' => 'Internal server error while deleting trash files.'], 500);
|
$this->_jsonOut(['error' => 'Internal server error while deleting trash files.'], 500);
|
||||||
} finally { $this->_jsonEnd(); }
|
} finally { $this->_jsonEnd(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFileTags(): void
|
public function getFileTags(): void
|
||||||
{
|
{
|
||||||
@@ -806,15 +934,23 @@ public function deleteTrashFiles()
|
|||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
$userPermissions = $this->loadPerms($username);
|
$userPermissions = $this->loadPerms($username);
|
||||||
|
|
||||||
if (!ACL::canWrite($username, $userPermissions, $folder)) {
|
// 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 write access"], 403); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Folder scope: write
|
||||||
|
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
|
||||||
|
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||||
|
|
||||||
|
// Ownership unless admin/folder-owner
|
||||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false))
|
||||||
|
|| ACL::isOwner($username, $userPermissions, $folder)
|
||||||
|
|| $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
|
||||||
if (!$ignoreOwnership) {
|
if (!$ignoreOwnership) {
|
||||||
$meta = $this->loadFolderMetadata($folder);
|
$meta = $this->loadFolderMetadata($folder);
|
||||||
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
|
if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) {
|
||||||
$this->_jsonOut(["error" => "Forbidden: you are not the owner of this file."], 403); return;
|
$this->_jsonOut(["error" => "Forbidden: you are not the owner of this file."], 403); return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -828,7 +964,7 @@ public function deleteTrashFiles()
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getFileList(): void
|
public function getFileList(): void
|
||||||
{
|
{
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
@@ -862,8 +998,10 @@ public function deleteTrashFiles()
|
|||||||
|
|
||||||
// ---- Folder-level view checks (full vs own-only) ----
|
// ---- Folder-level view checks (full vs own-only) ----
|
||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
$perms = $this->loadPerms($username); // your existing helper
|
$perms = $this->loadPerms($username);
|
||||||
$fullView = ACL::canRead($username, $perms, $folder);
|
|
||||||
|
// Full view if read OR ancestor owner
|
||||||
|
$fullView = ACL::canRead($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms);
|
||||||
$ownOnlyGrant = ACL::hasGrant($username, $folder, 'read_own');
|
$ownOnlyGrant = ACL::hasGrant($username, $folder, 'read_own');
|
||||||
|
|
||||||
if (!$fullView && !$ownOnlyGrant) {
|
if (!$fullView && !$ownOnlyGrant) {
|
||||||
@@ -896,7 +1034,6 @@ public function deleteTrashFiles()
|
|||||||
if (is_array($files) && array_keys($files) !== range(0, count($files) - 1)) {
|
if (is_array($files) && array_keys($files) !== range(0, count($files) - 1)) {
|
||||||
$filtered = [];
|
$filtered = [];
|
||||||
foreach ($files as $name => $meta) {
|
foreach ($files as $name => $meta) {
|
||||||
// SAFETY: only include when uploader is present AND matches
|
|
||||||
if (isset($meta['uploader']) && strcasecmp((string)$meta['uploader'], $username) === 0) {
|
if (isset($meta['uploader']) && strcasecmp((string)$meta['uploader'], $username) === 0) {
|
||||||
$filtered[$name] = $meta;
|
$filtered[$name] = $meta;
|
||||||
}
|
}
|
||||||
@@ -922,7 +1059,7 @@ public function deleteTrashFiles()
|
|||||||
} finally {
|
} finally {
|
||||||
restore_error_handler();
|
restore_error_handler();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getShareLinks()
|
public function getShareLinks()
|
||||||
{
|
{
|
||||||
@@ -979,11 +1116,13 @@ public function deleteTrashFiles()
|
|||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
$userPermissions = $this->loadPerms($username);
|
$userPermissions = $this->loadPerms($username);
|
||||||
|
|
||||||
if (!ACL::canWrite($username, $userPermissions, $folder)) {
|
// 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 write access"], 403); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions);
|
// Folder scope: write
|
||||||
|
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
|
||||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||||
|
|
||||||
$result = FileModel::createFile($folder, $filename, $username);
|
$result = FileModel::createFile($folder, $filename, $username);
|
||||||
|
|||||||
@@ -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,23 +186,15 @@ 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; }
|
||||||
@@ -194,19 +220,23 @@ class FolderController
|
|||||||
$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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let the model do the filesystem work AND seed ACL owner
|
// Folder-scope gate for folder-only accounts (need write on parent)
|
||||||
$result = FolderModel::createFolder($folderName, $parent, $username);
|
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);
|
echo json_encode($result);
|
||||||
exit;
|
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,7 +329,7 @@ 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();
|
||||||
|
|
||||||
@@ -299,24 +353,22 @@ class FolderController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$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([]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Admin sees all; others: include folder if user has full view OR own-only view
|
// 2) Filter by view rights
|
||||||
if (!$isAdmin) {
|
if (!$isAdmin) {
|
||||||
$all = array_values(array_filter($all, function ($row) use ($username, $perms) {
|
$all = array_values(array_filter($all, function ($row) use ($username, $perms) {
|
||||||
$f = $row['folder'] ?? '';
|
$f = $row['folder'] ?? '';
|
||||||
if ($f === '') return false;
|
if ($f === '') return false;
|
||||||
|
|
||||||
$fullView = ACL::canRead($username, $perms, $f); // owners|write|read
|
// Full view if canRead OR owns ancestor; otherwise allow if read_own granted
|
||||||
$ownOnly = ACL::hasGrant($username, $f, 'read_own'); // view-own
|
$fullView = ACL::canRead($username, $perms, $f) || FolderController::ownsFolderOrAncestor($f, $username, $perms);
|
||||||
|
$ownOnly = ACL::hasGrant($username, $f, 'read_own');
|
||||||
|
|
||||||
return $fullView || $ownOnly;
|
return $fullView || $ownOnly;
|
||||||
}));
|
}));
|
||||||
@@ -333,7 +385,7 @@ class FolderController
|
|||||||
|
|
||||||
echo json_encode($all);
|
echo json_encode($all);
|
||||||
exit;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user