$v) { if (strpos($k, 'HTTP_') === 0) { $name = strtolower(str_replace('_', '-', substr($k, 5))); $headers[$name] = $v; } } return $headers; } public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array { return FolderModel::listChildren($folder, $user, $perms, $cursor, $limit); } /** Stats for a folder (currently: empty/non-empty via folders/files counts). */ public static function stats(string $folder, string $user, array $perms): array { // Normalize inside model; this is a thin action return FolderModel::countVisible($folder, $user, $perms); } /** Capabilities for UI buttons/menus (unchanged semantics; just centralized). */ public static function capabilities(string $folder, string $username): array { $folder = ACL::normalizeFolder($folder); $perms = self::loadPermsFor($username); $isAdmin = ACL::isAdmin($perms); $folderOnly = self::boolFrom($perms, 'folderOnly','userFolderOnly','UserFolderOnly'); $readOnly = !empty($perms['readOnly']); $disableUpload = !empty($perms['disableUpload']); $isOwner = ACL::isOwner($username, $perms, $folder); $inScope = self::inUserFolderScope($folder, $username, $perms, $isAdmin, $folderOnly); $canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder); $canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder); $canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder); $gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder); $gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder); $gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder); $gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder); $gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder); $gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder); $gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder); $gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder); $gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder); $gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder); $canView = $canViewBase && $inScope; $canUpload = $gUploadBase && !$readOnly && !$disableUpload && $inScope; $canCreate = $gCreateBase && !$readOnly && $inScope; $canRename = $gRenameBase && !$readOnly && $inScope; $canDelete = $gDeleteBase && !$readOnly && $inScope; $canDeleteFile = $gDeleteBase && !$readOnly && $inScope; $canDeleteFolder = !$readOnly && $inScope && ( $isAdmin || $isOwner || ACL::canManage($username, $perms, $folder) || $gDeleteBase // if your ACL::canDelete should also allow folder deletes ); $canReceive = ($gUploadBase || $gCreateBase || $isAdmin) && !$readOnly && !$disableUpload && $inScope; $canMoveIn = $canReceive; $canEdit = $gEditBase && !$readOnly && $inScope; $canCopy = $gCopyBase && !$readOnly && $inScope; $canExtract = $gExtractBase && !$readOnly && $inScope; $canShareEff = $canShareBase && $inScope; $canShareFile = $gShareFile && $inScope; $canShareFold = $gShareFolder && $inScope; $isRoot = ($folder === 'root'); $canMoveFolder = false; if ($isRoot) { $canRename = false; $canDelete = false; $canShareFold = false; } else { $canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder)) && !$readOnly; } $owner = null; try { if (class_exists('FolderModel') && method_exists('FolderModel','getOwnerFor')) $owner = FolderModel::getOwnerFor($folder); } catch (\Throwable $e) {} return [ 'user' => $username, 'folder' => $folder, 'isAdmin' => $isAdmin, 'flags' => [ 'folderOnly' => $folderOnly, 'readOnly' => $readOnly, 'disableUpload' => $disableUpload, ], 'owner' => $owner, 'canView' => $canView, 'canViewOwn' => $canViewOwn, 'canUpload' => $canUpload, 'canCreate' => $canCreate, 'canRename' => $canRename, 'canDelete' => $canDeleteFile, 'canDeleteFolder' => $canDeleteFolder, 'canMoveIn' => $canMoveIn, 'canMove' => $canMoveIn, // legacy alias 'canMoveFolder' => $canMoveFolder, 'canEdit' => $canEdit, 'canCopy' => $canCopy, 'canExtract' => $canExtract, 'canShare' => $canShareEff, // legacy umbrella 'canShareFile' => $canShareFile, 'canShareFolder' => $canShareFold, ]; } /* --------------------------- Private helpers (caps) ----------------------------*/ private static function loadPermsFor(string $u): array { try { if (function_exists('loadUserPermissions')) { $p = loadUserPermissions($u); return is_array($p) ? $p : []; } if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) { $all = userModel::getUserPermissions(); if (is_array($all)) { if (isset($all[$u])) return (array)$all[$u]; $lk = strtolower($u); if (isset($all[$lk])) return (array)$all[$lk]; } } } catch (\Throwable $e) {} return []; } private static function boolFrom(array $a, string ...$keys): bool { foreach ($keys as $k) if (!empty($a[$k])) return true; return false; } private static function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool { $f = ACL::normalizeFolder($folder); if (ACL::isOwner($user, $perms, $f)) return true; while ($f !== '' && strcasecmp($f, 'root') !== 0) { $pos = strrpos($f, '/'); if ($pos === false) break; $f = substr($f, 0, $pos); if ($f === '' || strcasecmp($f, 'root') === 0) break; if (ACL::isOwner($user, $perms, $f)) return true; } return false; } private static function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin, bool $folderOnly): bool { if ($isAdmin) return true; if (!$folderOnly) return true; // normal users: global scope $f = ACL::normalizeFolder($folder); if ($f === 'root' || $f === '') { return self::isOwnerOrAncestorOwner($u, $perms, $f); } if ($f === $u || str_starts_with($f, $u . '/')) return true; return self::isOwnerOrAncestorOwner($u, $perms, $f); } private static function requireCsrf(): void { self::ensureSession(); $headers = self::getHeadersLower(); $received = trim($headers['x-csrf-token'] ?? ($_POST['csrfToken'] ?? '')); if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['error' => 'Invalid CSRF token']); exit; } } private static function requireAuth(): void { self::ensureSession(); if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(['error' => 'Unauthorized']); exit; } } /* -------------------- Permissions helpers -------------------- */ private static function loadPerms(string $username): array { try { if (function_exists('loadUserPermissions')) { $p = loadUserPermissions($username); return is_array($p) ? $p : []; } if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) { $all = userModel::getUserPermissions(); if (is_array($all)) { if (isset($all[$username])) return (array)$all[$username]; $lk = strtolower($username); if (isset($all[$lk])) return (array)$all[$lk]; } } } catch (\Throwable $e) { /* ignore */ } return []; } private static function migrateFolderColors(string $source, string $target): array { // PHP 8 polyfill if (!function_exists('str_starts_with')) { function str_starts_with(string $haystack, string $needle): bool { return $needle === '' || strncmp($haystack, $needle, strlen($needle)) === 0; } } $metaDir = rtrim(META_DIR, '/\\'); $file = $metaDir . '/folder_colors.json'; // Read current map (treat unreadable/invalid as empty) $raw = @file_get_contents($file); $map = is_string($raw) ? json_decode($raw, true) : []; if (!is_array($map)) $map = []; // Nothing to do fast-path $prefixSrc = $source; $prefixNeed = $source . '/'; $changed = false; $new = $map; $movedCount = 0; foreach ($map as $key => $hex) { if ($key === $prefixSrc || str_starts_with($key . '/', $prefixNeed)) { unset($new[$key]); $suffix = substr($key, strlen($prefixSrc)); // '' or '/sub/...' $newKey = ($target === 'root') ? ltrim($suffix, '/\\') : rtrim($target, '/\\') . $suffix; $new[$newKey] = $hex; $changed = true; $movedCount++; } } if ($changed) { // Write back (atomic-ish). Ignore failures (don’t block the move). $json = json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if (is_string($json)) { @file_put_contents($file, $json, LOCK_EX); @chmod($file, 0664); } } return ['changed' => $changed, 'moved' => $movedCount]; } private static function getPerms(): array { self::ensureSession(); $u = $_SESSION['username'] ?? ''; return $u ? self::loadPerms($u) : []; } private static function isAdmin(array $perms = []): bool { self::ensureSession(); if (!empty($_SESSION['isAdmin'])) return true; if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true; // Fallback: role from users.txt (role "1" means admin) $u = $_SESSION['username'] ?? ''; if ($u && class_exists('userModel') && method_exists('userModel', 'getUserRole')) { $roleStr = userModel::getUserRole($u); if ($roleStr === '1') return true; } return false; } private static function isFolderOnly(array $perms): bool { return !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']); } private static function requireNotReadOnly(): void { $perms = self::getPerms(); if (!empty($perms['readOnly'])) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['error' => 'Read-only users are not allowed to perform this action.']); exit; } } private static function requireAdmin(): void { $perms = self::getPerms(); if (!self::isAdmin($perms)) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['error' => 'Admin privileges required.']); exit; } } private static function formatBytes(int $bytes): string { if ($bytes < 1024) return $bytes . " B"; if ($bytes < 1048576) return round($bytes / 1024, 2) . " KB"; if ($bytes < 1073741824) return round($bytes / 1048576, 2) . " MB"; return round($bytes / 1073741824, 2) . " GB"; } /** Return true if user is explicit owner of the folder or any of its ancestors (admins also true). */ private static function ownsFolderOrAncestor(string $folder, string $username, array $perms): bool { if (self::isAdmin($perms)) return true; $folder = ACL::normalizeFolder($folder); $f = $folder; while ($f !== '' && strtolower($f) !== 'root') { if (ACL::isOwner($username, $perms, $f)) return true; $pos = strrpos($f, '/'); $f = ($pos === false) ? '' : substr($f, 0, $pos); } return false; } /** * Enforce per-folder scope for folder-only accounts. * $need: 'read' | 'write' | 'manage' | 'share' | 'read_own' (default 'read') * Returns null if allowed, or an error string if forbidden. */ // 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; // If this account isn't folder-scoped, don't gate here if (!self::isFolderOnly($perms)) return null; $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); } // 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 { if (self::isAdmin($perms)) return true; return (bool)($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); } /** ACL-aware folder owner check (explicit). */ private static function isFolderOwner(string $folder, string $username, array $perms): bool { return ACL::isOwner($username, $perms, $folder); } /* -------------------- 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.']); return; } self::requireCsrf(); self::requireNotReadOnly(); 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']) : 'root'; if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { 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.']); return; } $parent = ($parentIn === '' ? 'root' : $parentIn); $username = $_SESSION['username'] ?? ''; $perms = self::getPerms(); // 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: manager/owner required on parent.']); 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; } $result = FolderModel::createFolder($folderName, $parent, $username); if (empty($result['success'])) { http_response_code(400); echo json_encode($result); return; } echo json_encode($result); } 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 { 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(); $input = json_decode(file_get_contents('php://input'), true); if (!isset($input['folder'])) { http_response_code(400); echo json_encode(["error" => "Folder name not provided."]); exit; } $folder = trim((string)$input['folder']); if (strcasecmp($folder, 'root') === 0) { http_response_code(400); echo json_encode(["error" => "Cannot delete root folder."]); exit; } if (!preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; } $username = $_SESSION['username'] ?? ''; $perms = self::getPerms(); // Folder-scope: need manage (owner) OR explicit manage grant if ($msg = self::enforceFolderScope($folder, $username, $perms, 'manage')) { http_response_code(403); echo json_encode(["error" => $msg]); exit; } // Require either manage permission or ancestor ownership (strong gate) $canManage = ACL::canManage($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms); if (!$canManage) { http_response_code(403); echo json_encode(["error" => "Forbidden: you lack manage rights for this folder."]); exit; } // If not bypassing ownership, require ownership (direct or ancestor) as an extra safeguard if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) { http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the folder owner."]); exit; } $result = FolderModel::deleteFolder($folder); echo json_encode($result); exit; } /* -------------------- API: Rename Folder -------------------- */ public function renameFolder(): 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(); $input = json_decode(file_get_contents('php://input'), true); if (!isset($input['oldFolder']) || !isset($input['newFolder'])) { http_response_code(400); echo json_encode(['error' => 'Required folder names not provided.']); exit; } $oldFolder = trim((string)$input['oldFolder']); $newFolder = trim((string)$input['newFolder']); if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) { http_response_code(400); echo json_encode(['error' => 'Invalid folder name(s).']); exit; } $username = $_SESSION['username'] ?? ''; $perms = self::getPerms(); // Must be allowed to manage the old folder if ($msg = self::enforceFolderScope($oldFolder, $username, $perms, 'manage')) { http_response_code(403); echo json_encode(["error" => $msg]); exit; } // For the new folder path, require write scope (we're "creating" a path) if ($msg = self::enforceFolderScope($newFolder, $username, $perms, '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 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; } // If not bypassing ownership, require ownership (direct or ancestor) on the old folder if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($oldFolder, $username, $perms)) { http_response_code(403); echo json_encode(['error' => 'Forbidden: you are not the folder owner.']); exit; } $result = FolderModel::renameFolder($oldFolder, $newFolder); echo json_encode($result); exit; } /* -------------------- API: Get Folder List -------------------- */ public function getFolderList(): void { header('Content-Type: application/json'); self::requireAuth(); // Optional "folder" filter (supports nested like "team/reports") $parent = $_GET['folder'] ?? null; if ($parent !== null && $parent !== '' && strcasecmp($parent, 'root') !== 0) { $parts = array_filter(explode('/', trim($parent, "/\\ ")), fn($p) => $p !== ''); if (empty($parts)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; } foreach ($parts as $seg) { if (!preg_match(REGEX_FOLDER_NAME, $seg)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; } } $parent = implode('/', $parts); } $username = $_SESSION['username'] ?? ''; $perms = self::getPerms(); $isAdmin = self::isAdmin($perms); // 1) Full list from model $all = FolderModel::getFolderList(); // each row: ["folder","fileCount","metadataFile"] if (!is_array($all)) { echo json_encode([]); exit; } // 2) Filter by view rights if (!$isAdmin) { $all = array_values(array_filter($all, function ($row) use ($username, $perms) { $f = $row['folder'] ?? ''; if ($f === '') return false; // Full view if canRead OR owns ancestor; otherwise allow if read_own granted $fullView = ACL::canRead($username, $perms, $f) || FolderController::ownsFolderOrAncestor($f, $username, $perms); $ownOnly = ACL::hasGrant($username, $f, 'read_own'); return $fullView || $ownOnly; })); } // 3) Optional parent filter (applies to both admin and non-admin) if ($parent && strcasecmp($parent, 'root') !== 0) { $pref = $parent . '/'; $all = array_values(array_filter($all, function ($row) use ($parent, $pref) { $f = $row['folder'] ?? ''; return ($f === $parent) || (strpos($f, $pref) === 0); })); } echo json_encode($all); exit; } /* -------------------- Public Shared Folder HTML -------------------- */ public function shareFolder(): void { $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); $providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING); $page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT); if ($page === false || $page < 1) $page = 1; if (empty($token)) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "Missing token."]); exit; } $data = FolderModel::getSharedFolderData($token, $providedPass, $page); if (isset($data['needs_password']) && $data['needs_password'] === true) { header("Content-Type: text/html; charset=utf-8"); ?>
This folder is protected by a password. Please enter the password to view its contents.