feat(acl): granular per-folder permissions + stricter gates; WebDAV & UI aligned
This commit is contained in:
@@ -65,6 +65,37 @@ class FileController
|
||||
return [];
|
||||
}
|
||||
|
||||
private static function folderOfPath(string $path): string {
|
||||
// normalize path to folder; files: use dirname, folders: return path
|
||||
$p = trim(str_replace('\\', '/', $path), "/ \t\r\n");
|
||||
if ($p === '' || $p === 'root') return 'root';
|
||||
// If it ends with a slash or is an existing folder path, treat as folder
|
||||
if (substr($p, -1) === '/') $p = rtrim($p, '/');
|
||||
// For files, take the parent folder
|
||||
$dir = dirname($p);
|
||||
return ($dir === '.' || $dir === '') ? 'root' : $dir;
|
||||
}
|
||||
|
||||
private static function ensureSrcDstAllowedForCopy(
|
||||
string $user, array $perms, string $srcPath, string $dstFolder
|
||||
): bool {
|
||||
$srcFolder = ACL::normalizeFolder(self::folderOfPath($srcPath));
|
||||
$dstFolder = ACL::normalizeFolder($dstFolder);
|
||||
// Need to be able to see the source (own or full) and copy into destination
|
||||
return ACL::canReadOwn($user, $perms, $srcFolder)
|
||||
&& ACL::canCopy($user, $perms, $dstFolder);
|
||||
}
|
||||
|
||||
private static function ensureSrcDstAllowedForMove(
|
||||
string $user, array $perms, string $srcPath, string $dstFolder
|
||||
): bool {
|
||||
$srcFolder = ACL::normalizeFolder(self::folderOfPath($srcPath));
|
||||
$dstFolder = ACL::normalizeFolder($dstFolder);
|
||||
// Move removes from source and adds to dest
|
||||
return ACL::canDelete($user, $perms, $srcFolder)
|
||||
&& ACL::canMove($user, $perms, $dstFolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ownership-only enforcement for a set of files in a folder.
|
||||
* Returns null if OK, or an error string.
|
||||
@@ -135,21 +166,26 @@ class FileController
|
||||
// Otherwise, require the specific capability on the target folder
|
||||
$ok = false;
|
||||
switch ($need) {
|
||||
case 'manage':
|
||||
$ok = ACL::canManage($username, $userPermissions, $folder);
|
||||
break;
|
||||
case 'write':
|
||||
$ok = ACL::canWrite($username, $userPermissions, $folder);
|
||||
break;
|
||||
case 'share':
|
||||
$ok = ACL::canShare($username, $userPermissions, $folder);
|
||||
break;
|
||||
case 'read_own':
|
||||
$ok = ACL::canReadOwn($username, $userPermissions, $folder);
|
||||
break;
|
||||
default: // 'read'
|
||||
$ok = ACL::canRead($username, $userPermissions, $folder);
|
||||
}
|
||||
case 'manage': $ok = ACL::canManage($username, $userPermissions, $folder); break;
|
||||
case 'write': $ok = ACL::canWrite($username, $userPermissions, $folder); break; // legacy
|
||||
case 'share': $ok = ACL::canShare($username, $userPermissions, $folder); break; // legacy
|
||||
case 'read_own': $ok = ACL::canReadOwn($username, $userPermissions, $folder); break;
|
||||
// granular:
|
||||
case 'create': $ok = ACL::canCreate($username, $userPermissions, $folder); break;
|
||||
case 'upload': $ok = ACL::canUpload($username, $userPermissions, $folder); break;
|
||||
case 'edit': $ok = ACL::canEdit($username, $userPermissions, $folder); break;
|
||||
case 'rename': $ok = ACL::canRename($username, $userPermissions, $folder); break;
|
||||
case 'copy': $ok = ACL::canCopy($username, $userPermissions, $folder); break;
|
||||
case 'move': $ok = ACL::canMove($username, $userPermissions, $folder); break;
|
||||
case 'delete': $ok = ACL::canDelete($username, $userPermissions, $folder); break;
|
||||
case 'extract': $ok = ACL::canExtract($username, $userPermissions, $folder); break;
|
||||
case 'shareFile':
|
||||
case 'share_file': $ok = ACL::canShareFile($username, $userPermissions, $folder); break;
|
||||
case 'shareFolder':
|
||||
case 'share_folder': $ok = ACL::canShareFolder($username, $userPermissions, $folder); break;
|
||||
default: // 'read'
|
||||
$ok = ACL::canRead($username, $userPermissions, $folder);
|
||||
}
|
||||
|
||||
return $ok ? null : "Forbidden: folder scope violation.";
|
||||
}
|
||||
@@ -209,110 +245,157 @@ class FileController
|
||||
* Actions
|
||||
* ========================= */
|
||||
|
||||
public function copyFiles()
|
||||
{
|
||||
$this->_jsonStart();
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
public function copyFiles()
|
||||
{
|
||||
$this->_jsonStart();
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
|
||||
$data = $this->_readJsonBody();
|
||||
if (
|
||||
!$data
|
||||
|| !isset($data['source'], $data['destination'], $data['files'])
|
||||
|| !is_array($data['files'])
|
||||
) {
|
||||
$this->_jsonOut(["error" => "Invalid request"], 400); return;
|
||||
}
|
||||
|
||||
$sourceFolder = $this->_normalizeFolder($data['source']);
|
||||
$destinationFolder = $this->_normalizeFolder($data['destination']);
|
||||
$files = array_values(array_filter(array_map('basename', (array)$data['files'])));
|
||||
|
||||
$data = $this->_readJsonBody();
|
||||
if (!$data || !isset($data['source'], $data['destination'], $data['files']) || !is_array($data['files'])) {
|
||||
$this->_jsonOut(["error" => "Invalid request"], 400); return;
|
||||
|
||||
if (!$this->_validFolder($sourceFolder) || !$this->_validFolder($destinationFolder)) {
|
||||
$this->_jsonOut(["error" => "Invalid folder name(s)."], 400); return;
|
||||
}
|
||||
if (empty($files)) {
|
||||
$this->_jsonOut(["error" => "No files specified."], 400); return;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// --- Permission gates (granular) ------------------------------------
|
||||
// Source: own-only view is enough to copy (we'll enforce ownership below if no full read)
|
||||
$hasSourceView = ACL::canReadOwn($username, $userPermissions, $sourceFolder)
|
||||
|| $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions);
|
||||
if (!$hasSourceView) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no read access to source"], 403); return;
|
||||
}
|
||||
|
||||
// Destination: must have 'copy' capability (or own ancestor)
|
||||
$hasDestCreate = ACL::canCreate($username, $userPermissions, $destinationFolder)
|
||||
|| $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions);
|
||||
if (!$hasDestCreate) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no write access to destination"], 403); return;
|
||||
}
|
||||
|
||||
$sourceFolder = $this->_normalizeFolder($data['source']);
|
||||
$destinationFolder = $this->_normalizeFolder($data['destination']);
|
||||
$files = $data['files'];
|
||||
$needSrcScope = ACL::canRead($username, $userPermissions, $sourceFolder) ? 'read' : 'read_own';
|
||||
|
||||
// Folder-scope checks with the needed capabilities
|
||||
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, $needSrcScope);
|
||||
if ($sv) { $this->_jsonOut(["error" => $sv], 403); return; }
|
||||
|
||||
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'create');
|
||||
if ($dv) { $this->_jsonOut(["error" => $dv], 403); return; }
|
||||
|
||||
// If the user doesn't have full read on source (only read_own), enforce per-file ownership
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
|
||||
if (
|
||||
!$ignoreOwnership
|
||||
&& !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read
|
||||
&& ACL::hasGrant($username, $sourceFolder, 'read_own') // but has own-only
|
||||
) {
|
||||
$ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
|
||||
if ($ownErr) { $this->_jsonOut(["error" => $ownErr], 403); return; }
|
||||
}
|
||||
|
||||
if (!$this->_validFolder($sourceFolder) || !$this->_validFolder($destinationFolder)) {
|
||||
$this->_jsonOut(["error" => "Invalid folder name(s)."], 400); return;
|
||||
}
|
||||
// Account flags: copy writes new objects into destination
|
||||
if (!empty($userPermissions['readOnly'])) {
|
||||
$this->_jsonOut(["error" => "Account is read-only."], 403); return;
|
||||
}
|
||||
if (!empty($userPermissions['disableUpload'])) {
|
||||
$this->_jsonOut(["error" => "Uploads are disabled for your account."], 403); return;
|
||||
}
|
||||
|
||||
// --- Do the copy ----------------------------------------------------
|
||||
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
|
||||
$this->_jsonOut($result);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::copyFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while copying files.'], 500);
|
||||
} finally {
|
||||
$this->_jsonEnd();
|
||||
}
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
public function deleteFiles()
|
||||
{
|
||||
$this->_jsonStart();
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
|
||||
// Gate: need read on source (or ancestor-owner) and write on destination (or ancestor-owner)
|
||||
if (!(ACL::canRead($username, $userPermissions, $sourceFolder) || $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no read access to source"], 403); return;
|
||||
}
|
||||
if (!(ACL::canWrite($username, $userPermissions, $destinationFolder) || $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
|
||||
}
|
||||
$data = $this->_readJsonBody();
|
||||
if (!is_array($data) || !isset($data['files']) || !is_array($data['files'])) {
|
||||
$this->_jsonOut(["error" => "No file names provided"], 400); return;
|
||||
}
|
||||
|
||||
// Folder-scope checks with the needed capabilities
|
||||
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'read');
|
||||
if ($sv) { $this->_jsonOut(["error"=>$sv], 403); return; }
|
||||
// sanitize/normalize the list (empty names filtered out)
|
||||
$files = array_values(array_filter(array_map('strval', $data['files']), fn($s) => $s !== ''));
|
||||
if (!$files) {
|
||||
$this->_jsonOut(["error" => "No file names provided"], 400); return;
|
||||
}
|
||||
|
||||
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'write');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
$folder = $this->_normalizeFolder($data['folder'] ?? 'root');
|
||||
if (!$this->_validFolder($folder)) {
|
||||
$this->_jsonOut(["error" => "Invalid folder name."], 400); return;
|
||||
}
|
||||
|
||||
// If the user doesn't have full read on source (only read_own), enforce per-file ownership
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
if (
|
||||
!$ignoreOwnership
|
||||
&& !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read
|
||||
&& ACL::hasGrant($username, $sourceFolder, 'read_own') // but has own-only
|
||||
) {
|
||||
$ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
|
||||
if ($ownErr) { $this->_jsonOut(["error"=>$ownErr], 403); return; }
|
||||
}
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
|
||||
$this->_jsonOut($result);
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::copyFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while copying files.'], 500);
|
||||
} finally { $this->_jsonEnd(); }
|
||||
}
|
||||
// --- Permission gates (granular) ------------------------------------
|
||||
// Need delete on folder (or ancestor-owner)
|
||||
$hasDelete = ACL::canDelete($username, $userPermissions, $folder)
|
||||
|| $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
|
||||
if (!$hasDelete) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no delete permission"], 403); return;
|
||||
}
|
||||
|
||||
public function deleteFiles()
|
||||
{
|
||||
$this->_jsonStart();
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
// --- Folder-scope check (granular) ----------------------------------
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'delete');
|
||||
if ($dv) { $this->_jsonOut(["error" => $dv], 403); return; }
|
||||
|
||||
$data = $this->_readJsonBody();
|
||||
if (!isset($data['files']) || !is_array($data['files'])) {
|
||||
$this->_jsonOut(["error" => "No file names provided"], 400); return;
|
||||
}
|
||||
// --- Ownership enforcement when user only has viewOwn ----------------
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
$isFolderOwner = ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
|
||||
|
||||
$folder = $this->_normalizeFolder($data['folder'] ?? 'root');
|
||||
if (!$this->_validFolder($folder)) {
|
||||
$this->_jsonOut(["error" => "Invalid folder name."], 400); return;
|
||||
}
|
||||
// If user is not owner/admin and does NOT have full view, but does have own-only, enforce per-file ownership
|
||||
if (
|
||||
!$ignoreOwnership
|
||||
&& !$isFolderOwner
|
||||
&& !ACL::canRead($username, $userPermissions, $folder) // lacks full read
|
||||
&& ACL::hasGrant($username, $folder, 'read_own') // has own-only
|
||||
) {
|
||||
$ownErr = $this->enforceScopeAndOwnership($folder, $files, $username, $userPermissions);
|
||||
if ($ownErr) { $this->_jsonOut(["error" => $ownErr], 403); return; }
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
// --- Perform delete --------------------------------------------------
|
||||
$result = FileModel::deleteFiles($folder, $files);
|
||||
$this->_jsonOut($result);
|
||||
|
||||
// Need write (or ancestor-owner)
|
||||
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
|
||||
}
|
||||
|
||||
// Folder-scope: write
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
// Ownership enforcement for non-admins who are not folder owners
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
$isFolderOwner = ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions);
|
||||
|
||||
if (!$ignoreOwnership && !$isFolderOwner) {
|
||||
$violation = $this->enforceScopeAndOwnership($folder, $data['files'], $username, $userPermissions);
|
||||
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
||||
}
|
||||
|
||||
$result = FileModel::deleteFiles($folder, $data['files']);
|
||||
$this->_jsonOut($result);
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::deleteFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while deleting files.'], 500);
|
||||
} finally { $this->_jsonEnd(); }
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::deleteFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while deleting files.'], 500);
|
||||
} finally { $this->_jsonEnd(); }
|
||||
}
|
||||
|
||||
public function moveFiles()
|
||||
{
|
||||
@@ -320,41 +403,59 @@ class FileController
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
|
||||
|
||||
$data = $this->_readJsonBody();
|
||||
if (!$data || !isset($data['source'], $data['destination'], $data['files']) || !is_array($data['files'])) {
|
||||
if (
|
||||
!$data
|
||||
|| !isset($data['source'], $data['destination'], $data['files'])
|
||||
|| !is_array($data['files'])
|
||||
) {
|
||||
$this->_jsonOut(["error" => "Invalid request"], 400); return;
|
||||
}
|
||||
|
||||
|
||||
$sourceFolder = $this->_normalizeFolder($data['source']);
|
||||
$destinationFolder = $this->_normalizeFolder($data['destination']);
|
||||
if (!$this->_validFolder($sourceFolder) || !$this->_validFolder($destinationFolder)) {
|
||||
$this->_jsonOut(["error" => "Invalid folder name(s)."], 400); return;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// Require write on both ends (or ancestor-owner on each end)
|
||||
if (!(ACL::canWrite($username, $userPermissions, $sourceFolder) || $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access to source"], 403); return;
|
||||
|
||||
$files = $data['files'];
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// --- Permission gates (granular) ------------------------------------
|
||||
// Must be able to at least SEE the source and DELETE there
|
||||
$hasSourceView = ACL::canReadOwn($username, $userPermissions, $sourceFolder)
|
||||
|| $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions);
|
||||
if (!$hasSourceView) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no read access to source"], 403); return;
|
||||
}
|
||||
if (!(ACL::canWrite($username, $userPermissions, $destinationFolder) || $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
|
||||
|
||||
$hasSourceDelete = ACL::canDelete($username, $userPermissions, $sourceFolder)
|
||||
|| $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions);
|
||||
if (!$hasSourceDelete) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no delete permission on source"], 403); return;
|
||||
}
|
||||
|
||||
$files = $data['files'];
|
||||
|
||||
// Folder scope: need WRITE on both ends for a move
|
||||
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'write');
|
||||
if ($sv) { $this->_jsonOut(["error"=>$sv], 403); return; }
|
||||
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'write');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
// If the user doesn't have full read on source (only read_own), enforce per-file ownership
|
||||
|
||||
// Destination must allow MOVE
|
||||
$hasDestMove = ACL::canMove($username, $userPermissions, $destinationFolder)
|
||||
|| $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions);
|
||||
if (!$hasDestMove) {
|
||||
$this->_jsonOut(["error" => "Forbidden: no move permission on destination"], 403); return;
|
||||
}
|
||||
|
||||
// --- Folder-scope checks --------------------------------------------
|
||||
// Source needs 'delete' scope; destination needs 'move' scope
|
||||
$sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'delete');
|
||||
if ($sv) { $this->_jsonOut(["error" => $sv], 403); return; }
|
||||
|
||||
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions, 'move');
|
||||
if ($dv) { $this->_jsonOut(["error" => $dv], 403); return; }
|
||||
|
||||
// --- Ownership enforcement when only viewOwn on source --------------
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
|
||||
|
||||
if (
|
||||
!$ignoreOwnership
|
||||
&& !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read
|
||||
@@ -363,13 +464,17 @@ class FileController
|
||||
$ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
|
||||
if ($ownErr) { $this->_jsonOut(["error"=>$ownErr], 403); return; }
|
||||
}
|
||||
|
||||
|
||||
// --- Perform move ----------------------------------------------------
|
||||
$result = FileModel::moveFiles($sourceFolder, $destinationFolder, $files);
|
||||
$this->_jsonOut($result);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::moveFiles error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
$this->_jsonOut(['error' => 'Internal server error while moving files.'], 500);
|
||||
} finally { $this->_jsonEnd(); }
|
||||
} finally {
|
||||
$this->_jsonEnd();
|
||||
}
|
||||
}
|
||||
|
||||
public function renameFile()
|
||||
@@ -378,12 +483,12 @@ class FileController
|
||||
try {
|
||||
if (!$this->_checkCsrf()) return;
|
||||
if (!$this->_requireAuth()) return;
|
||||
|
||||
|
||||
$data = $this->_readJsonBody();
|
||||
if (!$data || !isset($data['folder'], $data['oldName'], $data['newName'])) {
|
||||
$this->_jsonOut(["error" => "Invalid input"], 400); return;
|
||||
}
|
||||
|
||||
|
||||
$folder = $this->_normalizeFolder($data['folder']);
|
||||
$oldName = basename(trim((string)$data['oldName']));
|
||||
$newName = basename(trim((string)$data['newName']));
|
||||
@@ -391,19 +496,19 @@ class FileController
|
||||
if (!$this->_validFile($oldName) || !$this->_validFile($newName)) {
|
||||
$this->_jsonOut(["error"=>"Invalid file name(s)."], 400); return;
|
||||
}
|
||||
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// Need write (or ancestor-owner)
|
||||
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
|
||||
|
||||
// Need granular rename (or ancestor-owner)
|
||||
if (!(ACL::canRename($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no rename rights"], 403); return;
|
||||
}
|
||||
|
||||
// Folder scope: write
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
|
||||
|
||||
// Folder scope: rename
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'rename');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
|
||||
// Ownership for non-admins when not a folder owner
|
||||
$ignoreOwnership = $this->isAdmin($userPermissions)
|
||||
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
|
||||
@@ -412,7 +517,7 @@ class FileController
|
||||
$violation = $this->enforceScopeAndOwnership($folder, [$oldName], $username, $userPermissions);
|
||||
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
|
||||
}
|
||||
|
||||
|
||||
$result = FileModel::renameFile($folder, $oldName, $newName);
|
||||
if (!is_array($result)) throw new RuntimeException('FileModel::renameFile returned non-array');
|
||||
if (isset($result['error'])) { $this->_jsonOut($result, 400); return; }
|
||||
@@ -444,12 +549,12 @@ class FileController
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// Need write (or ancestor-owner)
|
||||
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
|
||||
if (!(ACL::canEdit($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no full write access"], 403); return;
|
||||
}
|
||||
|
||||
// Folder scope: write
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'edit');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
// If overwriting, enforce ownership for non-admins (unless folder owner)
|
||||
@@ -580,7 +685,7 @@ class FileController
|
||||
$perms = $this->loadPerms($username);
|
||||
|
||||
// Optional zip gate by account flag
|
||||
if (!$this->isAdmin($perms) && array_key_exists('canZip', $perms) && !$perms['canZip']) {
|
||||
if (!$this->isAdmin($perms) && !empty($perms['disableZip'])) {
|
||||
$this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403); return;
|
||||
}
|
||||
|
||||
@@ -652,12 +757,12 @@ class FileController
|
||||
$perms = $this->loadPerms($username);
|
||||
|
||||
// must be able to write into target folder (or be ancestor-owner)
|
||||
if (!(ACL::canWrite($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return;
|
||||
if (!(ACL::canExtract($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no full write access to destination"], 403); return;
|
||||
}
|
||||
|
||||
// Folder scope: write
|
||||
$dv = $this->enforceFolderScope($folder, $username, $perms, 'write');
|
||||
$dv = $this->enforceFolderScope($folder, $username, $perms, 'extract');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
$result = FileModel::extractZipArchive($folder, $data['files']);
|
||||
@@ -785,7 +890,7 @@ class FileController
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// Need share (or ancestor-owner)
|
||||
if (!(ACL::canShare($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
if (!(ACL::canShareFile($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no share access"], 403); return;
|
||||
}
|
||||
|
||||
@@ -936,7 +1041,7 @@ class FileController
|
||||
|
||||
// Need write (or ancestor-owner)
|
||||
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
|
||||
$this->_jsonOut(["error"=>"Forbidden: no full write access"], 403); return;
|
||||
}
|
||||
|
||||
// Folder scope: write
|
||||
@@ -967,49 +1072,78 @@ class FileController
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
|
||||
// convert warnings/notices to exceptions for cleaner error handling
|
||||
set_error_handler(function ($severity, $message, $file, $line) {
|
||||
if (!(error_reporting() & $severity)) return;
|
||||
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
|
||||
|
||||
try {
|
||||
if (empty($_SESSION['username'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!is_dir(META_DIR)) @mkdir(META_DIR, 0775, true);
|
||||
|
||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid folder name.']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir(UPLOAD_DIR)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Uploads directory not found.']);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// --- inputs ---
|
||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||
|
||||
// Validate folder path: allow "root" or nested segments that each match REGEX_FOLDER_NAME
|
||||
if ($folder !== 'root') {
|
||||
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
|
||||
if (empty($parts)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid folder name.']);
|
||||
return;
|
||||
}
|
||||
foreach ($parts as $seg) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid folder name.']);
|
||||
return;
|
||||
}
|
||||
}
|
||||
$folder = implode('/', $parts);
|
||||
}
|
||||
|
||||
// ---- Folder-level view checks (full vs own-only) ----
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$perms = $this->loadPerms($username);
|
||||
|
||||
|
||||
// Full view if read OR ancestor owner
|
||||
$fullView = ACL::canRead($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms);
|
||||
$fullView = ACL::canRead($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms);
|
||||
$ownOnlyGrant = ACL::hasGrant($username, $folder, 'read_own');
|
||||
|
||||
if (!$fullView && !$ownOnlyGrant) {
|
||||
|
||||
// Special-case: keep Root visible but inert if user lacks any visibility there.
|
||||
if ($folder === 'root' && !$fullView && !$ownOnlyGrant) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'folder' => 'root',
|
||||
'files' => [],
|
||||
// Optional hint the UI can use to show a soft message / disable actions:
|
||||
'uiHints' => [
|
||||
'noAccessRoot' => true,
|
||||
'message' => "You don't have access to Root. Select a folder you have access to."
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-root: still enforce 403 if no visibility
|
||||
if ($folder !== 'root' && !$fullView && !$ownOnlyGrant) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden: no view access to this folder.']);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Fetch the list
|
||||
$result = FileModel::getFileList($folder);
|
||||
if ($result === false || $result === null) {
|
||||
@@ -1025,12 +1159,12 @@ class FileController
|
||||
echo json_encode($result);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// ---- Apply own-only filter if user does NOT have full view ----
|
||||
if (!$fullView && $ownOnlyGrant && isset($result['files'])) {
|
||||
$files = $result['files'];
|
||||
|
||||
// If files keyed by filename
|
||||
|
||||
// If files keyed by filename (assoc array)
|
||||
if (is_array($files) && array_keys($files) !== range(0, count($files) - 1)) {
|
||||
$filtered = [];
|
||||
foreach ($files as $name => $meta) {
|
||||
@@ -1040,7 +1174,7 @@ class FileController
|
||||
}
|
||||
$result['files'] = $filtered;
|
||||
}
|
||||
// If files are a numeric array of metadata
|
||||
// If files is a numeric array of metadata items
|
||||
else if (is_array($files)) {
|
||||
$result['files'] = array_values(array_filter(
|
||||
$files,
|
||||
@@ -1050,7 +1184,7 @@ class FileController
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
error_log('FileController::getFileList error: '.$e->getMessage().' in '.$e->getFile().':'.$e->getLine());
|
||||
@@ -1117,12 +1251,12 @@ class FileController
|
||||
$userPermissions = $this->loadPerms($username);
|
||||
|
||||
// Need write (or ancestor-owner)
|
||||
if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return;
|
||||
if (!(ACL::canCreate($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) {
|
||||
$this->_jsonOut(["error"=>"Forbidden: no full write access"], 403); return;
|
||||
}
|
||||
|
||||
// Folder scope: write
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write');
|
||||
$dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'create');
|
||||
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
|
||||
|
||||
$result = FileModel::createFile($folder, $filename, $username);
|
||||
|
||||
@@ -150,35 +150,65 @@ class FolderController
|
||||
* $need: 'read' | 'write' | 'manage' | 'share' | 'read_own' (default 'read')
|
||||
* Returns null if allowed, or an error string if forbidden.
|
||||
*/
|
||||
private static function enforceFolderScope(string $folder, string $username, array $perms, string $need = 'read'): ?string
|
||||
{
|
||||
// Admins bypass scope
|
||||
if (self::isAdmin($perms)) return null;
|
||||
// In FolderController.php
|
||||
private static function enforceFolderScope(
|
||||
string $folder,
|
||||
string $username,
|
||||
array $perms,
|
||||
string $need = 'read'
|
||||
): ?string {
|
||||
// Admins bypass scope
|
||||
if (self::isAdmin($perms)) return null;
|
||||
|
||||
// Not a folder-only account? no gate here
|
||||
if (!self::isFolderOnly($perms)) return null;
|
||||
// If this account isn't folder-scoped, don't gate here
|
||||
if (!self::isFolderOnly($perms)) return null;
|
||||
|
||||
$folder = ACL::normalizeFolder($folder);
|
||||
$folder = ACL::normalizeFolder($folder);
|
||||
|
||||
// If user owns folder or an ancestor, allow
|
||||
$f = $folder;
|
||||
while ($f !== '' && strtolower($f) !== 'root') {
|
||||
if (ACL::isOwner($username, $perms, $f)) return null;
|
||||
$pos = strrpos($f, '/');
|
||||
$f = ($pos === false) ? '' : substr($f, 0, $pos);
|
||||
}
|
||||
|
||||
// Otherwise, require specific capability on the target folder
|
||||
switch ($need) {
|
||||
case 'manage': $ok = ACL::canManage($username, $perms, $folder); break;
|
||||
case 'write': $ok = ACL::canWrite($username, $perms, $folder); break;
|
||||
case 'share': $ok = ACL::canShare($username, $perms, $folder); break;
|
||||
case 'read_own': $ok = ACL::canReadOwn($username, $perms, $folder);break;
|
||||
default: $ok = ACL::canRead($username, $perms, $folder);
|
||||
}
|
||||
return $ok ? null : "Forbidden: folder scope violation.";
|
||||
// If user owns folder or an ancestor, allow
|
||||
$f = $folder;
|
||||
while ($f !== '' && strtolower($f) !== 'root') {
|
||||
if (ACL::isOwner($username, $perms, $f)) return null;
|
||||
$pos = strrpos($f, '/');
|
||||
$f = ($pos === false) ? '' : substr($f, 0, $pos);
|
||||
}
|
||||
|
||||
// Normalize aliases so callers can pass either camelCase or snake_case
|
||||
switch ($need) {
|
||||
case 'manage': $ok = ACL::canManage($username, $perms, $folder); break;
|
||||
|
||||
// legacy:
|
||||
case 'write': $ok = ACL::canWrite($username, $perms, $folder); break;
|
||||
case 'share': $ok = ACL::canShare($username, $perms, $folder); break;
|
||||
|
||||
// read flavors:
|
||||
case 'read_own': $ok = ACL::canReadOwn($username, $perms, $folder); break;
|
||||
case 'read': $ok = ACL::canRead($username, $perms, $folder); break;
|
||||
|
||||
// granular write-ish:
|
||||
case 'create': $ok = ACL::canCreate($username, $perms, $folder); break;
|
||||
case 'upload': $ok = ACL::canUpload($username, $perms, $folder); break;
|
||||
case 'edit': $ok = ACL::canEdit($username, $perms, $folder); break;
|
||||
case 'rename': $ok = ACL::canRename($username, $perms, $folder); break;
|
||||
case 'copy': $ok = ACL::canCopy($username, $perms, $folder); break;
|
||||
case 'move': $ok = ACL::canMove($username, $perms, $folder); break;
|
||||
case 'delete': $ok = ACL::canDelete($username, $perms, $folder); break;
|
||||
case 'extract': $ok = ACL::canExtract($username, $perms, $folder); break;
|
||||
|
||||
// granular share (support both key styles)
|
||||
case 'shareFile':
|
||||
case 'share_file': $ok = ACL::canShareFile($username, $perms, $folder); break;
|
||||
case 'shareFolder':
|
||||
case 'share_folder':$ok = ACL::canShareFolder($username, $perms, $folder); break;
|
||||
|
||||
default:
|
||||
// Default to full read if unknown need was passed
|
||||
$ok = ACL::canRead($username, $perms, $folder);
|
||||
}
|
||||
|
||||
return $ok ? null : "Forbidden: folder scope violation.";
|
||||
}
|
||||
|
||||
/** Returns true if caller can ignore ownership (admin or bypassOwnership/default). */
|
||||
private static function canBypassOwnership(array $perms): bool
|
||||
{
|
||||
@@ -194,49 +224,58 @@ class FolderController
|
||||
|
||||
/* -------------------- API: Create Folder -------------------- */
|
||||
public function createFolder(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
self::requireAuth();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit; }
|
||||
self::requireCsrf();
|
||||
self::requireNotReadOnly();
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
self::requireAuth();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); return; }
|
||||
self::requireCsrf();
|
||||
self::requireNotReadOnly();
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!isset($input['folderName'])) { http_response_code(400); echo json_encode(['error' => 'Folder name not provided.']); exit; }
|
||||
try {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
if (!isset($input['folderName'])) { http_response_code(400); echo json_encode(['error' => 'Folder name not provided.']); return; }
|
||||
|
||||
$folderName = trim((string)$input['folderName']);
|
||||
$parentIn = isset($input['parent']) ? trim((string)$input['parent']) : '';
|
||||
$parentIn = isset($input['parent']) ? trim((string)$input['parent']) : 'root';
|
||||
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||
http_response_code(400); echo json_encode(['error' => 'Invalid folder name.']); exit;
|
||||
http_response_code(400); echo json_encode(['error' => 'Invalid folder name.']); return;
|
||||
}
|
||||
if ($parentIn !== '' && strcasecmp($parentIn, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parentIn)) {
|
||||
http_response_code(400); echo json_encode(['error' => 'Invalid parent folder name.']); exit;
|
||||
http_response_code(400); echo json_encode(['error' => 'Invalid parent folder name.']); return;
|
||||
}
|
||||
|
||||
// Normalize parent to an ACL key
|
||||
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
|
||||
$parent = ($parentIn === '' ? 'root' : $parentIn);
|
||||
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$perms = self::getPerms();
|
||||
|
||||
// Must be able to write into parent OR be owner (or ancestor owner) of it
|
||||
if (!(ACL::canWrite($username, $perms, $parent) || self::ownsFolderOrAncestor($parent, $username, $perms))) {
|
||||
// Need create on parent OR ownership on parent/ancestor
|
||||
if (!(ACL::canCreateFolder($username, $perms, $parent) || self::ownsFolderOrAncestor($parent, $username, $perms))) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden: no write access to parent folder.']);
|
||||
echo json_encode(['error' => 'Forbidden: manager/owner required on parent.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Folder-scope gate for folder-only accounts (need write on parent)
|
||||
if ($msg = self::enforceFolderScope($parent, $username, $perms, 'write')) {
|
||||
http_response_code(403); echo json_encode(['error' => $msg]); exit;
|
||||
// Folder-scope gate for folder-only accounts (need create on parent)
|
||||
if ($msg = self::enforceFolderScope($parent, $username, $perms, 'manage')) {
|
||||
http_response_code(403); echo json_encode(['error' => $msg]); return;
|
||||
}
|
||||
|
||||
// Model should create folder and seed ACL (owner = creator)
|
||||
$result = FolderModel::createFolder($folderName, $parent, $username);
|
||||
if (empty($result['success'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode($result);
|
||||
return;
|
||||
}
|
||||
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
error_log('createFolder fatal: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Internal error creating folder.']);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- API: Delete Folder -------------------- */
|
||||
public function deleteFolder(): void
|
||||
@@ -307,11 +346,11 @@ class FolderController
|
||||
http_response_code(403); echo json_encode(["error" => $msg]); exit;
|
||||
}
|
||||
// For the new folder path, require write scope (we're "creating" a path)
|
||||
if ($msg = self::enforceFolderScope($newFolder, $username, $perms, 'write')) {
|
||||
if ($msg = self::enforceFolderScope($newFolder, $username, $perms, 'manage')) {
|
||||
http_response_code(403); echo json_encode(["error" => "New path not allowed: " . $msg]); exit;
|
||||
}
|
||||
|
||||
// Strong gates: need manage on old OR ancestor owner; need write on new parent or ancestor owner
|
||||
// Strong gates: need manage on old OR ancestor owner; need manage on new parent OR ancestor owner
|
||||
$canManageOld = ACL::canManage($username, $perms, $oldFolder) || self::ownsFolderOrAncestor($oldFolder, $username, $perms);
|
||||
if (!$canManageOld) {
|
||||
http_response_code(403); echo json_encode(['error' => 'Forbidden: you lack manage rights on the source folder.']); exit;
|
||||
|
||||
@@ -57,7 +57,7 @@ class UploadController {
|
||||
$targetFolder = ACL::normalizeFolder($folderParam);
|
||||
|
||||
// Admins bypass folder canWrite checks
|
||||
if (!$isAdmin && !ACL::canWrite($username, $userPerms, $targetFolder)) {
|
||||
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
|
||||
return;
|
||||
|
||||
294
src/lib/ACL.php
294
src/lib/ACL.php
@@ -6,23 +6,20 @@ require_once PROJECT_ROOT . '/config/config.php';
|
||||
|
||||
class ACL
|
||||
{
|
||||
/** In-memory cache of the ACL file. */
|
||||
private static $cache = null;
|
||||
/** Absolute path to folder_acl.json */
|
||||
private static $path = null;
|
||||
|
||||
/** Capability buckets we store per folder. */
|
||||
private const BUCKETS = ['owners','read','write','share','read_own']; // + read_own (view own only)
|
||||
private const BUCKETS = [
|
||||
'owners','read','write','share','read_own',
|
||||
'create','upload','edit','rename','copy','move','delete','extract',
|
||||
'share_file','share_folder'
|
||||
];
|
||||
|
||||
/** Compute/cache the ACL storage path. */
|
||||
private static function path(): string {
|
||||
if (!self::$path) {
|
||||
self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||
}
|
||||
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||
return self::$path;
|
||||
}
|
||||
|
||||
/** Normalize folder names (slashes + root). */
|
||||
public static function normalizeFolder(string $f): string {
|
||||
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
|
||||
if ($f === '' || $f === 'root') return 'root';
|
||||
@@ -33,23 +30,19 @@ class ACL
|
||||
$user = (string)$user;
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$changed = false;
|
||||
|
||||
foreach ($acl['folders'] as $folder => &$rec) {
|
||||
foreach (self::BUCKETS as $k) {
|
||||
$before = $rec[$k] ?? [];
|
||||
$before = is_array($rec[$k] ?? null) ? $rec[$k] : [];
|
||||
$rec[$k] = array_values(array_filter($before, fn($u) => strcasecmp((string)$u, $user) !== 0));
|
||||
if ($rec[$k] !== $before) $changed = true;
|
||||
}
|
||||
}
|
||||
unset($rec);
|
||||
|
||||
return $changed ? self::save($acl) : true;
|
||||
}
|
||||
|
||||
/** Load ACL fresh from disk, create/heal if needed. */
|
||||
private static function loadFresh(): array {
|
||||
$path = self::path();
|
||||
|
||||
if (!is_file($path)) {
|
||||
@mkdir(dirname($path), 0755, true);
|
||||
$init = [
|
||||
@@ -59,7 +52,17 @@ class ACL
|
||||
'read' => ['admin'],
|
||||
'write' => ['admin'],
|
||||
'share' => ['admin'],
|
||||
'read_own'=> [], // new bucket; empty by default
|
||||
'read_own'=> [],
|
||||
'create' => [],
|
||||
'upload' => [],
|
||||
'edit' => [],
|
||||
'rename' => [],
|
||||
'copy' => [],
|
||||
'move' => [],
|
||||
'delete' => [],
|
||||
'extract' => [],
|
||||
'share_file' => [],
|
||||
'share_folder' => [],
|
||||
],
|
||||
],
|
||||
'groups' => [],
|
||||
@@ -70,12 +73,9 @@ class ACL
|
||||
$json = (string) @file_get_contents($path);
|
||||
$data = json_decode($json, true);
|
||||
if (!is_array($data)) $data = [];
|
||||
|
||||
// Normalize shape
|
||||
$data['folders'] = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
|
||||
$data['groups'] = isset($data['groups']) && is_array($data['groups']) ? $data['groups'] : [];
|
||||
|
||||
// Ensure root exists and has all buckets
|
||||
if (!isset($data['folders']['root']) || !is_array($data['folders']['root'])) {
|
||||
$data['folders']['root'] = [
|
||||
'owners' => ['admin'],
|
||||
@@ -84,16 +84,8 @@ class ACL
|
||||
'share' => ['admin'],
|
||||
'read_own' => [],
|
||||
];
|
||||
} else {
|
||||
foreach (self::BUCKETS as $k) {
|
||||
if (!isset($data['folders']['root'][$k]) || !is_array($data['folders']['root'][$k])) {
|
||||
// sensible defaults: admin in the classic buckets, empty for read_own
|
||||
$data['folders']['root'][$k] = ($k === 'read_own') ? [] : ['admin'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Heal any folder records
|
||||
$healed = false;
|
||||
foreach ($data['folders'] as $folder => &$rec) {
|
||||
if (!is_array($rec)) { $rec = []; $healed = true; }
|
||||
@@ -107,30 +99,22 @@ class ACL
|
||||
unset($rec);
|
||||
|
||||
self::$cache = $data;
|
||||
|
||||
// Persist back if we healed anything
|
||||
if ($healed) {
|
||||
@file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
}
|
||||
|
||||
if ($healed) @file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** Persist ACL to disk and refresh cache. */
|
||||
private static function save(array $acl): bool {
|
||||
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
|
||||
if ($ok) self::$cache = $acl;
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/** Get a bucket list (owners/read/write/share/read_own) for a folder (explicit only). */
|
||||
private static function listFor(string $folder, string $key): array {
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$f = $acl['folders'][$folder] ?? null;
|
||||
return is_array($f[$key] ?? null) ? $f[$key] : [];
|
||||
}
|
||||
|
||||
/** Ensure a folder record exists (giving an initial owner). */
|
||||
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
@@ -141,18 +125,26 @@ class ACL
|
||||
'write' => [$owner],
|
||||
'share' => [$owner],
|
||||
'read_own' => [],
|
||||
'create' => [],
|
||||
'upload' => [],
|
||||
'edit' => [],
|
||||
'rename' => [],
|
||||
'copy' => [],
|
||||
'move' => [],
|
||||
'delete' => [],
|
||||
'extract' => [],
|
||||
'share_file' => [],
|
||||
'share_folder' => [],
|
||||
];
|
||||
self::save($acl);
|
||||
}
|
||||
}
|
||||
|
||||
/** True if this request is admin. */
|
||||
public static function isAdmin(array $perms = []): bool {
|
||||
if (!empty($_SESSION['isAdmin'])) return true;
|
||||
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
|
||||
if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
|
||||
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
|
||||
// Optional: if you configured DEFAULT_ADMIN_USER, treat that username as admin
|
||||
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
|
||||
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
|
||||
return true;
|
||||
@@ -160,24 +152,19 @@ class ACL
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Case-insensitive membership in a capability bucket. $cap: owner|owners|read|write|share|read_own */
|
||||
public static function hasGrant(string $user, string $folder, string $cap): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$capKey = ($cap === 'owner') ? 'owners' : $cap;
|
||||
$arr = self::listFor($folder, $capKey);
|
||||
foreach ($arr as $u) {
|
||||
if (strcasecmp((string)$u, $user) === 0) return true;
|
||||
}
|
||||
foreach ($arr as $u) if (strcasecmp((string)$u, $user) === 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True if user is an explicit owner (or admin). */
|
||||
public static function isOwner(string $user, array $perms, string $folder): bool {
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
|
||||
/** "Manage" in UI == owner. */
|
||||
public static function canManage(string $user, array $perms, string $folder): bool {
|
||||
return self::isOwner($user, $perms, $folder);
|
||||
}
|
||||
@@ -185,19 +172,15 @@ class ACL
|
||||
public static function canRead(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
// IMPORTANT: write no longer implies read
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'read');
|
||||
}
|
||||
|
||||
/** Own-only view = read_own OR (any full view). */
|
||||
public static function canReadOwn(string $user, array $perms, string $folder): bool {
|
||||
// if they can full-view, this is trivially true
|
||||
if (self::canRead($user, $perms, $folder)) return true;
|
||||
return self::hasGrant($user, $folder, 'read_own');
|
||||
}
|
||||
|
||||
/** Upload = write OR owner. No bypassOwnership. */
|
||||
public static function canWrite(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
@@ -205,7 +188,6 @@ class ACL
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
/** Share = share OR owner. No bypassOwnership. */
|
||||
public static function canShare(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
@@ -213,10 +195,7 @@ class ACL
|
||||
|| self::hasGrant($user, $folder, 'share');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return explicit lists for a folder (no inheritance).
|
||||
* Keys: owners, read, write, share, read_own (always arrays).
|
||||
*/
|
||||
// Legacy-only explicit (to avoid breaking existing callers)
|
||||
public static function explicit(string $folder): array {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
@@ -235,10 +214,35 @@ class ACL
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a full explicit record for a folder.
|
||||
* NOTE: preserves existing 'read_own' so older callers don't wipe it.
|
||||
*/
|
||||
// New: full explicit including granular
|
||||
public static function explicitAll(string $folder): array {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$rec = $acl['folders'][$folder] ?? [];
|
||||
$norm = function ($v): array {
|
||||
if (!is_array($v)) return [];
|
||||
$v = array_map('strval', $v);
|
||||
return array_values(array_unique($v));
|
||||
};
|
||||
return [
|
||||
'owners' => $norm($rec['owners'] ?? []),
|
||||
'read' => $norm($rec['read'] ?? []),
|
||||
'write' => $norm($rec['write'] ?? []),
|
||||
'share' => $norm($rec['share'] ?? []),
|
||||
'read_own' => $norm($rec['read_own'] ?? []),
|
||||
'create' => $norm($rec['create'] ?? []),
|
||||
'upload' => $norm($rec['upload'] ?? []),
|
||||
'edit' => $norm($rec['edit'] ?? []),
|
||||
'rename' => $norm($rec['rename'] ?? []),
|
||||
'copy' => $norm($rec['copy'] ?? []),
|
||||
'move' => $norm($rec['move'] ?? []),
|
||||
'delete' => $norm($rec['delete'] ?? []),
|
||||
'extract' => $norm($rec['extract'] ?? []),
|
||||
'share_file' => $norm($rec['share_file'] ?? []),
|
||||
'share_folder' => $norm($rec['share_folder'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
@@ -251,24 +255,23 @@ class ACL
|
||||
'read' => $fmt($read),
|
||||
'write' => $fmt($write),
|
||||
'share' => $fmt($share),
|
||||
// preserve any own-only grants unless caller explicitly manages them elsewhere
|
||||
'read_own' => isset($existing['read_own']) && is_array($existing['read_own'])
|
||||
? array_values(array_unique(array_map('strval', $existing['read_own'])))
|
||||
: [],
|
||||
'create' => isset($existing['create']) && is_array($existing['create']) ? array_values(array_unique(array_map('strval', $existing['create']))) : [],
|
||||
'upload' => isset($existing['upload']) && is_array($existing['upload']) ? array_values(array_unique(array_map('strval', $existing['upload']))) : [],
|
||||
'edit' => isset($existing['edit']) && is_array($existing['edit']) ? array_values(array_unique(array_map('strval', $existing['edit']))) : [],
|
||||
'rename' => isset($existing['rename']) && is_array($existing['rename']) ? array_values(array_unique(array_map('strval', $existing['rename']))) : [],
|
||||
'copy' => isset($existing['copy']) && is_array($existing['copy']) ? array_values(array_unique(array_map('strval', $existing['copy']))) : [],
|
||||
'move' => isset($existing['move']) && is_array($existing['move']) ? array_values(array_unique(array_map('strval', $existing['move']))) : [],
|
||||
'delete' => isset($existing['delete']) && is_array($existing['delete']) ? array_values(array_unique(array_map('strval', $existing['delete']))) : [],
|
||||
'extract' => isset($existing['extract']) && is_array($existing['extract']) ? array_values(array_unique(array_map('strval', $existing['extract']))) : [],
|
||||
'share_file' => isset($existing['share_file']) && is_array($existing['share_file']) ? array_values(array_unique(array_map('strval', $existing['share_file']))) : [],
|
||||
'share_folder' => isset($existing['share_folder']) && is_array($existing['share_folder']) ? array_values(array_unique(array_map('strval', $existing['share_folder']))) : [],
|
||||
];
|
||||
return self::save($acl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic per-user update across many folders.
|
||||
* $grants is like:
|
||||
* [
|
||||
* "folderA" => ["view"=>true, "viewOwn"=>false, "upload"=>true, "manage"=>false, "share"=>false],
|
||||
* "folderB" => ["view"=>false, "viewOwn"=>true, "upload"=>false, "manage"=>false, "share"=>false],
|
||||
* ]
|
||||
* If a folder is INCLUDED with all false, the user is removed from all its buckets.
|
||||
* (If the frontend omits a folder entirely, this method leaves that folder unchanged.)
|
||||
*/
|
||||
public static function applyUserGrantsAtomic(string $user, array $grants): array {
|
||||
$user = (string)$user;
|
||||
$path = self::path();
|
||||
@@ -278,7 +281,6 @@ class ACL
|
||||
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); }
|
||||
|
||||
try {
|
||||
// Read current content
|
||||
$raw = stream_get_contents($fh);
|
||||
if ($raw === false) $raw = '';
|
||||
$acl = json_decode($raw, true);
|
||||
@@ -290,38 +292,59 @@ class ACL
|
||||
|
||||
foreach ($grants as $folder => $caps) {
|
||||
$ff = self::normalizeFolder((string)$folder);
|
||||
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) {
|
||||
$acl['folders'][$ff] = ['owners'=>[], 'read'=>[], 'write'=>[], 'share'=>[], 'read_own'=>[]];
|
||||
}
|
||||
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
|
||||
$rec =& $acl['folders'][$ff];
|
||||
|
||||
// Remove user from all buckets first (idempotent)
|
||||
foreach (self::BUCKETS as $k) {
|
||||
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
|
||||
}
|
||||
foreach (self::BUCKETS as $k) {
|
||||
$arr = is_array($rec[$k]) ? $rec[$k] : [];
|
||||
$rec[$k] = array_values(array_filter(
|
||||
array_map('strval', $rec[$k]),
|
||||
fn($u) => strcasecmp($u, $user) !== 0
|
||||
array_map('strval', $arr),
|
||||
fn($u) => strcasecmp((string)$u, $user) !== 0
|
||||
));
|
||||
}
|
||||
|
||||
$v = !empty($caps['view']); // full view
|
||||
$vo = !empty($caps['viewOwn']); // own-only view
|
||||
$u = !empty($caps['upload']);
|
||||
$m = !empty($caps['manage']);
|
||||
$s = !empty($caps['share']);
|
||||
$v = !empty($caps['view']);
|
||||
$vo = !empty($caps['viewOwn']);
|
||||
$u = !empty($caps['upload']);
|
||||
$m = !empty($caps['manage']);
|
||||
$s = !empty($caps['share']);
|
||||
$w = !empty($caps['write']);
|
||||
|
||||
// Implications
|
||||
if ($m) { $v = true; $u = true; } // owner implies read+write
|
||||
if ($u && !$v && !$vo) $vo = true; // upload needs at least own-only visibility
|
||||
if ($s && !$v) $v = true; // sharing implies full read (can be relaxed if desired)
|
||||
$c = !empty($caps['create']);
|
||||
$ed = !empty($caps['edit']);
|
||||
$rn = !empty($caps['rename']);
|
||||
$cp = !empty($caps['copy']);
|
||||
$mv = !empty($caps['move']);
|
||||
$dl = !empty($caps['delete']);
|
||||
$ex = !empty($caps['extract']);
|
||||
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
|
||||
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
|
||||
|
||||
// Add back per caps
|
||||
if ($m) $rec['owners'][] = $user;
|
||||
if ($v) $rec['read'][] = $user;
|
||||
if ($vo) $rec['read_own'][]= $user;
|
||||
if ($u) $rec['write'][] = $user;
|
||||
if ($s) $rec['share'][] = $user;
|
||||
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $mv = $dl = $ex = $sf = $sfo = true; }
|
||||
if ($u && !$v && !$vo) $vo = true;
|
||||
//if ($s && !$v) $v = true;
|
||||
if ($w) { $c = $u = $ed = $rn = $cp = $mv = $dl = $ex = true; }
|
||||
|
||||
if ($m) $rec['owners'][] = $user;
|
||||
if ($v) $rec['read'][] = $user;
|
||||
if ($vo) $rec['read_own'][] = $user;
|
||||
if ($w) $rec['write'][] = $user;
|
||||
if ($s) $rec['share'][] = $user;
|
||||
|
||||
if ($u) $rec['upload'][] = $user;
|
||||
if ($c) $rec['create'][] = $user;
|
||||
if ($ed) $rec['edit'][] = $user;
|
||||
if ($rn) $rec['rename'][] = $user;
|
||||
if ($cp) $rec['copy'][] = $user;
|
||||
if ($mv) $rec['move'][] = $user;
|
||||
if ($dl) $rec['delete'][] = $user;
|
||||
if ($ex) $rec['extract'][] = $user;
|
||||
if ($sf) $rec['share_file'][] = $user;
|
||||
if ($sfo)$rec['share_folder'][] = $user;
|
||||
|
||||
// De-dup
|
||||
foreach (self::BUCKETS as $k) {
|
||||
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
|
||||
}
|
||||
@@ -330,7 +353,6 @@ class ACL
|
||||
unset($rec);
|
||||
}
|
||||
|
||||
// Write back atomically
|
||||
ftruncate($fh, 0);
|
||||
rewind($fh);
|
||||
$ok = fwrite($fh, json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== false;
|
||||
@@ -344,4 +366,92 @@ class ACL
|
||||
fclose($fh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Granular write family -----------------------------------------------
|
||||
|
||||
public static function canCreate(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'create')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canCreateFolder(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
// Only owners/managers can create subfolders under $folder
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
|
||||
public static function canUpload(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'upload')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canEdit(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'edit')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canRename(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'rename')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canCopy(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'copy')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canMove(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'move')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canDelete(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'delete')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canExtract(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'extract')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
/** Sharing: files use share, folders require share + full-view. */
|
||||
public static function canShareFile(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||
}
|
||||
public static function canShareFolder(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||
if (!$can) return false;
|
||||
// require full view too
|
||||
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'read');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,48 +169,67 @@ class FolderModel
|
||||
* @param string $parent 'root' or nested key (e.g. 'team/reports')
|
||||
* @param string $creator username to set as initial owner (falls back to 'admin')
|
||||
*/
|
||||
public static function createFolder(string $folderName, string $parent = 'root', string $creator = 'admin'): array
|
||||
public static function createFolder(string $folderName, string $parent, string $creator): array
|
||||
{
|
||||
// -------- Normalize incoming values (use ONLY the parameters) --------
|
||||
$folderName = trim((string)$folderName);
|
||||
$parentIn = trim((string)$parent);
|
||||
|
||||
// If the client sent a path in folderName (e.g., "bob/new-sub") and parent is root/empty,
|
||||
// derive parent = "bob" and folderName = "new-sub" so permission checks hit "bob".
|
||||
$normalized = ACL::normalizeFolder($folderName);
|
||||
if ($normalized !== 'root' && strpos($normalized, '/') !== false &&
|
||||
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)) {
|
||||
$parentIn = trim(str_replace('\\', '/', dirname($normalized)), '/');
|
||||
$folderName = basename($normalized);
|
||||
if ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) $parentIn = 'root';
|
||||
}
|
||||
|
||||
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
|
||||
$folderName = trim($folderName);
|
||||
$parent = trim($parent);
|
||||
|
||||
if ($folderName === '' || !preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||
return ['success' => false, 'error' => 'Invalid folder name', 'code' => 400];
|
||||
if ($folderName === '') return ['success'=>false, 'error' => 'Folder name required'];
|
||||
|
||||
// ACL key for new folder
|
||||
$newKey = ($parent === 'root') ? $folderName : ($parent . '/' . $folderName);
|
||||
|
||||
// -------- Compose filesystem paths --------
|
||||
$base = rtrim((string)UPLOAD_DIR, "/\\");
|
||||
$parentRel = ($parent === 'root') ? '' : str_replace('/', DIRECTORY_SEPARATOR, $parent);
|
||||
$parentAbs = $parentRel ? ($base . DIRECTORY_SEPARATOR . $parentRel) : $base;
|
||||
$newAbs = $parentAbs . DIRECTORY_SEPARATOR . $folderName;
|
||||
|
||||
// -------- Exists / sanity checks --------
|
||||
if (!is_dir($parentAbs)) return ['success'=>false, 'error' => 'Parent folder does not exist'];
|
||||
if (is_dir($newAbs)) return ['success'=>false, 'error' => 'Folder already exists'];
|
||||
|
||||
// -------- Create directory --------
|
||||
if (!@mkdir($newAbs, 0775, true)) {
|
||||
$err = error_get_last();
|
||||
return ['success'=>false, 'error' => 'Failed to create folder' . (!empty($err['message']) ? (': '.$err['message']) : '')];
|
||||
}
|
||||
if ($parent !== '' && strcasecmp($parent, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parent)) {
|
||||
return ['success' => false, 'error' => 'Invalid parent folder', 'code' => 400];
|
||||
|
||||
// -------- Seed ACL --------
|
||||
$inherit = defined('ACL_INHERIT_ON_CREATE') && ACL_INHERIT_ON_CREATE;
|
||||
try {
|
||||
if ($inherit) {
|
||||
// Copy parent’s explicit (legacy 5 buckets), add creator to owners
|
||||
$p = ACL::explicit($parent); // owners, read, write, share, read_own
|
||||
$owners = array_values(array_unique(array_map('strval', array_merge($p['owners'], [$creator]))));
|
||||
$read = $p['read'];
|
||||
$write = $p['write'];
|
||||
$share = $p['share'];
|
||||
ACL::upsert($newKey, $owners, $read, $write, $share);
|
||||
} else {
|
||||
// Creator owns the new folder
|
||||
ACL::ensureFolderRecord($newKey, $creator);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Roll back FS if ACL seeding fails
|
||||
@rmdir($newAbs);
|
||||
return ['success'=>false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
|
||||
}
|
||||
|
||||
// Compute ACL key and filesystem path
|
||||
$aclKey = ($parent === '' || strcasecmp($parent, 'root') === 0) ? $folderName : ($parent . '/' . $folderName);
|
||||
|
||||
$base = rtrim(UPLOAD_DIR, '/\\');
|
||||
$path = ($parent === '' || strcasecmp($parent, 'root') === 0)
|
||||
? $base . DIRECTORY_SEPARATOR . $folderName
|
||||
: $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $parent) . DIRECTORY_SEPARATOR . $folderName;
|
||||
|
||||
// Safety: stay inside UPLOAD_DIR
|
||||
$realBase = realpath($base);
|
||||
$realPath = $path; // may not exist yet
|
||||
$parentDir = dirname($path);
|
||||
if (!is_dir($parentDir) && !@mkdir($parentDir, 0775, true)) {
|
||||
return ['success' => false, 'error' => 'Failed to create parent path', 'code' => 500];
|
||||
}
|
||||
|
||||
if (is_dir($path)) {
|
||||
// Idempotent: still ensure ACL record exists
|
||||
ACL::ensureFolderRecord($aclKey, $creator ?: 'admin');
|
||||
return ['success' => true, 'folder' => $aclKey, 'alreadyExists' => true];
|
||||
}
|
||||
|
||||
if (!@mkdir($path, 0775, true)) {
|
||||
return ['success' => false, 'error' => 'Failed to create folder', 'code' => 500];
|
||||
}
|
||||
|
||||
// Seed ACL: owner/read/write/share -> creator; read_own empty
|
||||
ACL::ensureFolderRecord($aclKey, $creator ?: 'admin');
|
||||
|
||||
return ['success' => true, 'folder' => $aclKey];
|
||||
|
||||
return ['success' => true, 'folder' => $newKey];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
namespace FileRise\WebDAV;
|
||||
|
||||
//src/webdav/FileRiseDirectory.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php'; // constants + loadUserPermissions()
|
||||
require_once __DIR__ . '/../../vendor/autoload.php'; // SabreDAV
|
||||
require_once __DIR__ . '/../../src/lib/ACL.php';
|
||||
@@ -166,9 +168,9 @@ class FileRiseDirectory implements ICollection, INode {
|
||||
|
||||
public function createDirectory($name): INode {
|
||||
$parentKey = $this->folderKeyForPath($this->path);
|
||||
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $parentKey)) {
|
||||
throw new Forbidden('No permission to create subfolders here');
|
||||
}
|
||||
if (!$this->isAdmin && !\ACL::canManage($this->user, $this->perms, $parentKey)) {
|
||||
throw new Forbidden('No permission to create subfolders here');
|
||||
}
|
||||
|
||||
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||
if (!is_dir($full)) {
|
||||
|
||||
@@ -38,8 +38,9 @@ class FileRiseFile implements IFile, INode {
|
||||
|
||||
public function delete(): void {
|
||||
[$folderKey, $fileName] = $this->split();
|
||||
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No write access to delete this file');
|
||||
|
||||
if (!$this->isAdmin && !\ACL::canDelete($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No delete permission in this folder');
|
||||
}
|
||||
if (!$this->canTouchOwnership($folderKey, $fileName)) {
|
||||
throw new Forbidden('You do not own this file');
|
||||
@@ -67,34 +68,40 @@ class FileRiseFile implements IFile, INode {
|
||||
|
||||
public function put($data): ?string {
|
||||
[$folderKey, $fileName] = $this->split();
|
||||
|
||||
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No write access to this folder');
|
||||
}
|
||||
if (!empty($this->perms['disableUpload']) && !$this->isAdmin) {
|
||||
throw new Forbidden('Uploads are disabled for your account');
|
||||
}
|
||||
|
||||
// If overwriting existing file, enforce ownership for non-admin unless bypassOwnership
|
||||
|
||||
$exists = is_file($this->path);
|
||||
$bypass = !empty($this->perms['bypassOwnership']);
|
||||
if ($exists && !$this->isAdmin && !$bypass && !$this->isOwner($folderKey, $fileName)) {
|
||||
|
||||
if (!$this->isAdmin) {
|
||||
// uploads disabled blocks both create & overwrite
|
||||
if (!empty($this->perms['disableUpload'])) {
|
||||
throw new Forbidden('Uploads are disabled for your account');
|
||||
}
|
||||
// granular gates
|
||||
if ($exists) {
|
||||
if (!\ACL::canEdit($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No edit permission in this folder');
|
||||
}
|
||||
} else {
|
||||
if (!\ACL::canUpload($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No upload permission in this folder');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ownership on overwrite (unless admin/bypass)
|
||||
$bypass = !empty($this->perms['bypassOwnership']) || $this->isAdmin;
|
||||
if ($exists && !$bypass && !$this->isOwner($folderKey, $fileName)) {
|
||||
throw new Forbidden('You do not own the target file');
|
||||
}
|
||||
|
||||
// Write data
|
||||
|
||||
// write + metadata (unchanged)
|
||||
file_put_contents(
|
||||
$this->path,
|
||||
is_resource($data) ? stream_get_contents($data) : (string)$data
|
||||
);
|
||||
|
||||
// Update metadata (uploader on first write; modified every write)
|
||||
$this->updateMetadata($folderKey, $fileName);
|
||||
|
||||
if (function_exists('fastcgi_finish_request')) {
|
||||
fastcgi_finish_request();
|
||||
}
|
||||
return null; // no ETag
|
||||
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getSize(): int {
|
||||
|
||||
Reference in New Issue
Block a user