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

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