getMetadataPath($folder); if (file_exists($meta)) { $data = json_decode(file_get_contents($meta), true); if (is_array($data)) return $data; } return []; } private 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 []; } /** * Ownership-only enforcement for a set of files in a folder. * Returns null if OK, or an error string. */ private function enforceScopeAndOwnership(string $folder, array $files, string $username, array $userPermissions): ?string { $ignoreOwnership = $this->isAdmin($userPermissions) || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); if ($ignoreOwnership) return null; $metadata = $this->loadFolderMetadata($folder); foreach ($files as $f) { $name = basename((string)$f); if (!isset($metadata[$name]['uploader']) || strcasecmp((string)$metadata[$name]['uploader'], $username) !== 0) { return "Forbidden: you are not the owner of '{$name}'."; } } return null; } /** * True if the user is an owner of the folder or any ancestor folder (admin also true). */ private function ownsFolderOrAncestor(string $folder, string $username, array $userPermissions): bool { if ($this->isAdmin($userPermissions)) return true; $folder = ACL::normalizeFolder($folder); // Direct folder first, then walk up ancestors (excluding 'root' sentinel) $f = $folder; while ($f !== '' && strtolower($f) !== 'root') { if (ACL::isOwner($username, $userPermissions, $f)) { return true; } $pos = strrpos($f, '/'); $f = ($pos === false) ? '' : substr($f, 0, $pos); } return false; } /** * Enforce per-folder scope when the account is in "folder-only" mode. * $need: 'read' (default) | 'write' | 'manage' | 'share' | 'read_own' * Returns null if allowed, or an error string if forbidden. */ private function enforceFolderScope( string $folder, string $username, array $userPermissions, string $need = 'read' ): ?string { // Admins bypass all folder scope checks if ($this->isAdmin($userPermissions)) return null; // If the account isn't restricted to a folder scope, don't gate here if (!$this->isFolderOnly($userPermissions)) return null; $folder = ACL::normalizeFolder($folder); // If user owns this folder (or any ancestor), allow $f = $folder; while ($f !== '' && strtolower($f) !== 'root') { if (ACL::isOwner($username, $userPermissions, $f)) { return null; } $pos = strrpos($f, '/'); $f = ($pos === false) ? '' : substr($f, 0, $pos); } // Otherwise, require the specific capability on the target folder $ok = false; switch ($need) { case 'manage': $ok = ACL::canManage($username, $userPermissions, $folder); break; case 'write': $ok = ACL::canWrite($username, $userPermissions, $folder); break; case 'share': $ok = ACL::canShare($username, $userPermissions, $folder); break; case 'read_own': $ok = ACL::canReadOwn($username, $userPermissions, $folder); break; default: // 'read' $ok = ACL::canRead($username, $userPermissions, $folder); } return $ok ? null : "Forbidden: folder scope violation."; } // --- small helpers --- private function _jsonStart(): void { if (session_status() !== PHP_SESSION_ACTIVE) session_start(); header('Content-Type: application/json; charset=utf-8'); set_error_handler(function ($severity, $message, $file, $line) { if (!(error_reporting() & $severity)) return; throw new ErrorException($message, 0, $severity, $file, $line); }); } private function _jsonEnd(): void { restore_error_handler(); } private function _jsonOut(array $payload, int $status = 200): void { http_response_code($status); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } private function _checkCsrf(): bool { $headersArr = function_exists('getallheaders') ? array_change_key_case(getallheaders(), CASE_LOWER) : []; $receivedToken = $headersArr['x-csrf-token'] ?? ''; if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) { $this->_jsonOut(['error' => 'Invalid CSRF token'], 403); return false; } return true; } private function _requireAuth(): bool { if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { $this->_jsonOut(['error' => 'Unauthorized'], 401); return false; } return true; } private function _readJsonBody(): array { $raw = file_get_contents('php://input'); $data = json_decode($raw, true); return is_array($data) ? $data : []; } private function _normalizeFolder($f): string { $f = trim((string)$f); if ($f === '' || strtolower($f) === 'root') return 'root'; return $f; } private function _validFolder($f): bool { if ($f === 'root') return true; return (bool)preg_match(REGEX_FOLDER_NAME, $f); } private function _validFile($f): bool { $f = basename((string)$f); return $f !== '' && (bool)preg_match(REGEX_FILE_NAME, $f); } /* ========================= * Actions * ========================= */ 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 = $data['files']; if (!$this->_validFolder($sourceFolder) || !$this->_validFolder($destinationFolder)) { $this->_jsonOut(["error" => "Invalid folder name(s)."], 400); return; } $username = $_SESSION['username'] ?? ''; $userPermissions = $this->loadPerms($username); // 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; } // Folder-scope checks with the needed capabilities $sv = $this->enforceFolderScope($sourceFolder, $username, $userPermissions, 'read'); 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 $ignoreOwnership = $this->isAdmin($userPermissions) || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); if ( !$ignoreOwnership && !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read && ACL::hasGrant($username, $sourceFolder, 'read_own') // but has own-only ) { $ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions); if ($ownErr) { $this->_jsonOut(["error"=>$ownErr], 403); return; } } $result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files); $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(); } } public function deleteFiles() { $this->_jsonStart(); try { if (!$this->_checkCsrf()) return; if (!$this->_requireAuth()) return; $data = $this->_readJsonBody(); if (!isset($data['files']) || !is_array($data['files'])) { $this->_jsonOut(["error" => "No file names provided"], 400); return; } $folder = $this->_normalizeFolder($data['folder'] ?? 'root'); if (!$this->_validFolder($folder)) { $this->_jsonOut(["error" => "Invalid folder name."], 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; } // 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(); } } public function moveFiles() { $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']); 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; } if (!(ACL::canWrite($username, $userPermissions, $destinationFolder) || $this->ownsFolderOrAncestor($destinationFolder, $username, $userPermissions))) { $this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 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 $ignoreOwnership = $this->isAdmin($userPermissions) || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); if ( !$ignoreOwnership && !ACL::canRead($username, $userPermissions, $sourceFolder) // no explicit full read && ACL::hasGrant($username, $sourceFolder, 'read_own') // but has own-only ) { $ownErr = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions); if ($ownErr) { $this->_jsonOut(["error"=>$ownErr], 403); return; } } $result = FileModel::moveFiles($sourceFolder, $destinationFolder, $files); $this->_jsonOut($result); } 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(); } } public function renameFile() { $this->_jsonStart(); 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'])); if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name"], 400); return; } 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; } // Folder scope: write $dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write'); if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } // Ownership for non-admins when not a folder owner $ignoreOwnership = $this->isAdmin($userPermissions) || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); $isFolderOwner = ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions); if (!$ignoreOwnership && !$isFolderOwner) { $violation = $this->enforceScopeAndOwnership($folder, [$oldName], $username, $userPermissions); 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; } $this->_jsonOut($result); } catch (Throwable $e) { error_log('FileController::renameFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); $this->_jsonOut(['error' => 'Internal server error while renaming file.'], 500); } finally { $this->_jsonEnd(); } } public function saveFile() { $this->_jsonStart(); try { if (!$this->_checkCsrf()) return; if (!$this->_requireAuth()) return; $data = $this->_readJsonBody(); if (empty($data) || !isset($data["fileName"])) { $this->_jsonOut(["error" => "Invalid request data"], 400); return; } $fileName = basename(trim((string)$data["fileName"])); $folder = $this->_normalizeFolder($data["folder"] ?? 'root'); if (!$this->_validFile($fileName)) { $this->_jsonOut(["error"=>"Invalid file name."], 400); return; } if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 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; } // Folder scope: write $dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write'); if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } // If overwriting, enforce ownership for non-admins (unless folder owner) $baseDir = rtrim(UPLOAD_DIR, '/\\'); $dir = ($folder === 'root') ? $baseDir : $baseDir . DIRECTORY_SEPARATOR . $folder; $path = $dir . DIRECTORY_SEPARATOR . $fileName; if (is_file($path)) { $ignoreOwnership = $this->isAdmin($userPermissions) || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)) || ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions); if (!$ignoreOwnership) { $violation = $this->enforceScopeAndOwnership($folder, [$fileName], $username, $userPermissions); if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } } } $deny = ['php','phtml','phar','php3','php4','php5','php7','php8','pht','shtml','cgi','fcgi']; $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); if (in_array($ext, $deny, true)) { $this->_jsonOut(['error' => 'Saving this file type is not allowed.'], 400); return; } $content = (string)($data['content'] ?? ''); $result = FileModel::saveFile($folder, $fileName, $content, $username); if (!is_array($result)) throw new RuntimeException('FileModel::saveFile returned non-array'); if (isset($result['error'])) { $this->_jsonOut($result, 400); return; } $this->_jsonOut($result); } catch (Throwable $e) { error_log('FileController::saveFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); $this->_jsonOut(['error' => 'Internal server error while saving file.'], 500); } finally { $this->_jsonEnd(); } } public function downloadFile() { if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(["error" => "Unauthorized"]); exit; } $file = isset($_GET['file']) ? basename($_GET['file']) : ''; $folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; if (!preg_match(REGEX_FILE_NAME, $file)) { http_response_code(400); echo json_encode(["error" => "Invalid file name."]); exit; } if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); echo json_encode(["error" => "Invalid folder name."]); exit; } $username = $_SESSION['username'] ?? ''; $perms = $this->loadPerms($username); $ignoreOwnership = $this->isAdmin($perms) || ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); // Treat ancestor-folder ownership as full view as well $fullView = $ignoreOwnership || ACL::canRead($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms); $ownGrant = !$fullView && ACL::hasGrant($username, $folder, 'read_own'); if (!$fullView && !$ownGrant) { http_response_code(403); echo json_encode(["error" => "Forbidden: no view access to this folder."]); exit; } // If own-only, enforce uploader==user if ($ownGrant) { $meta = $this->loadFolderMetadata($folder); if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) { http_response_code(403); echo json_encode(["error" => "Forbidden: you are not the owner of this file."]); exit; } } $downloadInfo = FileModel::getDownloadInfo($folder, $file); if (isset($downloadInfo['error'])) { http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400); echo json_encode(["error" => $downloadInfo['error']]); exit; } $realFilePath = $downloadInfo['filePath']; $mimeType = $downloadInfo['mimeType']; header("Content-Type: " . $mimeType); $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); $inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico']; if (in_array($ext, $inlineImageTypes, true)) { header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"'); } else { header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"'); } header('Content-Length: ' . filesize($realFilePath)); readfile($realFilePath); exit; } public function downloadZip() { $this->_jsonStart(); try { if (!$this->_checkCsrf()) return; if (!$this->_requireAuth()) return; $data = $this->_readJsonBody(); if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) { $this->_jsonOut(["error" => "Invalid input."], 400); return; } $folder = $this->_normalizeFolder($data['folder']); $files = $data['files']; if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; } $username = $_SESSION['username'] ?? ''; $perms = $this->loadPerms($username); // Optional zip gate by account flag if (!$this->isAdmin($perms) && array_key_exists('canZip', $perms) && !$perms['canZip']) { $this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403); return; } $ignoreOwnership = $this->isAdmin($perms) || ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)); // Ancestor-owner counts as full view $fullView = $ignoreOwnership || ACL::canRead($username, $perms, $folder) || $this->ownsFolderOrAncestor($folder, $username, $perms); $ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own'); if (!$fullView && !$ownOnly) { $this->_jsonOut(["error" => "Forbidden: no view access to this folder."], 403); return; } // If own-only, ensure all files are owned by the user if ($ownOnly) { $meta = $this->loadFolderMetadata($folder); foreach ($files as $f) { $bn = basename((string)$f); if (!isset($meta[$bn]['uploader']) || strcasecmp((string)$meta[$bn]['uploader'], $username) !== 0) { $this->_jsonOut(["error" => "Forbidden: you are not the owner of '{$bn}'."], 403); return; } } } $result = FileModel::createZipArchive($folder, $files); if (isset($result['error'])) { $this->_jsonOut(["error" => $result['error']], 400); return; } $zipPath = $result['zipPath'] ?? null; if (!$zipPath || !file_exists($zipPath)) { $this->_jsonOut(["error"=>"ZIP archive not found."], 500); return; } // switch to file streaming header_remove('Content-Type'); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="files.zip"'); header('Content-Length: ' . filesize($zipPath)); header('Cache-Control: no-store, no-cache, must-revalidate'); header('Pragma: no-cache'); readfile($zipPath); @unlink($zipPath); exit; } catch (Throwable $e) { error_log('FileController::downloadZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); $this->_jsonOut(['error' => 'Internal server error while preparing ZIP.'], 500); } finally { $this->_jsonEnd(); } } public function extractZip() { $this->_jsonStart(); try { if (!$this->_checkCsrf()) return; if (!$this->_requireAuth()) return; $data = $this->_readJsonBody(); if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) { $this->_jsonOut(["error" => "Invalid input."], 400); return; } $folder = $this->_normalizeFolder($data['folder']); if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; } $username = $_SESSION['username'] ?? ''; $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; } // Folder scope: write $dv = $this->enforceFolderScope($folder, $username, $perms, 'write'); if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } $result = FileModel::extractZipArchive($folder, $data['files']); $this->_jsonOut($result); } catch (Throwable $e) { error_log('FileController::extractZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); $this->_jsonOut(['error' => 'Internal server error while extracting ZIP.'], 500); } finally { $this->_jsonEnd(); } } public function shareFile() { $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); $providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING); if (empty($token)) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error" => "Missing token."]); exit; } $record = FileModel::getShareRecord($token); if (!$record) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(["error" => "Share link not found."]); exit; } if (time() > $record['expires']) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(["error" => "This link has expired."]); exit; } if (!empty($record['password']) && empty($providedPass)) { header("Content-Type: text/html; charset=utf-8"); ?> Enter Password

This file is protected by a password.

"Invalid password."]); exit; } } $folder = trim($record['folder'], "/\\ "); $file = $record['file']; $filePath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR; if (!empty($folder) && strtolower($folder) !== 'root') { $filePath .= $folder . DIRECTORY_SEPARATOR; } $filePath .= $file; $realFilePath = realpath($filePath); $uploadDirReal = realpath(UPLOAD_DIR); if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(["error" => "File not found."]); exit; } if (!file_exists($realFilePath)) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(["error" => "File not found."]); exit; } $mimeType = mime_content_type($realFilePath); header("Content-Type: " . $mimeType); $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'])) { header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"'); } else { header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"'); } header("Cache-Control: no-store, no-cache, must-revalidate"); header("Pragma: no-cache"); header('Content-Length: ' . filesize($realFilePath)); readfile($realFilePath); exit; } public function createShareLink() { $this->_jsonStart(); try { if (!$this->_requireAuth()) return; $input = $this->_readJsonBody(); if (!$input) { $this->_jsonOut(["error" => "Invalid input."], 400); return; } $folder = $this->_normalizeFolder($input['folder'] ?? ''); $file = basename((string)($input['file'] ?? '')); $value = isset($input['expirationValue']) ? (int)$input['expirationValue'] : 60; $unit = $input['expirationUnit'] ?? 'minutes'; $password = (string)($input['password'] ?? ''); if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; } if (!$this->_validFile($file)) { $this->_jsonOut(["error"=>"Invalid file name."], 400); return; } $username = $_SESSION['username'] ?? ''; $userPermissions = $this->loadPerms($username); // Need share (or ancestor-owner) if (!(ACL::canShare($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) { $this->_jsonOut(["error"=>"Forbidden: no share access"], 403); return; } // Folder scope: share $sv = $this->enforceFolderScope($folder, $username, $userPermissions, 'share'); if ($sv) { $this->_jsonOut(["error"=>$sv], 403); return; } // Ownership unless admin/folder-owner $ignoreOwnership = $this->isAdmin($userPermissions) || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)) || ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions); if (!$ignoreOwnership) { $meta = $this->loadFolderMetadata($folder); if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) { $this->_jsonOut(["error" => "Forbidden: you are not the owner of this file."], 403); return; } } switch ($unit) { case 'seconds': $expirationSeconds = $value; break; case 'hours': $expirationSeconds = $value * 3600; break; case 'days': $expirationSeconds = $value * 86400; break; case 'minutes': default: $expirationSeconds = $value * 60; break; } $result = FileModel::createShareLink($folder, $file, $expirationSeconds, $password); $this->_jsonOut($result); } catch (Throwable $e) { error_log('FileController::createShareLink error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); $this->_jsonOut(['error' => 'Internal server error while creating share link.'], 500); } finally { $this->_jsonEnd(); } } public function getTrashItems() { $this->_jsonStart(); try { if (!$this->_requireAuth()) return; $perms = $this->loadPerms($_SESSION['username'] ?? ''); if (!$this->isAdmin($perms)) { $this->_jsonOut(['error'=>'Admin only'], 403); return; } $trashItems = FileModel::getTrashItems(); $this->_jsonOut($trashItems); } catch (Throwable $e) { error_log('FileController::getTrashItems error: '.$e->getMessage()); $this->_jsonOut(['error' => 'Internal server error while fetching trash.'], 500); } finally { $this->_jsonEnd(); } } public function restoreFiles() { $this->_jsonStart(); try { if (!$this->_checkCsrf()) return; if (!$this->_requireAuth()) return; $perms = $this->loadPerms($_SESSION['username'] ?? ''); if (!$this->isAdmin($perms)) { $this->_jsonOut(['error'=>'Admin only'], 403); return; } $data = $this->_readJsonBody(); if (!isset($data['files']) || !is_array($data['files'])) { $this->_jsonOut(["error" => "No file or folder identifiers provided"], 400); return; } $result = FileModel::restoreFiles($data['files']); $this->_jsonOut($result); } catch (Throwable $e) { error_log('FileController::restoreFiles error: '.$e->getMessage()); $this->_jsonOut(['error' => 'Internal server error while restoring files.'], 500); } finally { $this->_jsonEnd(); } } public function deleteTrashFiles() { $this->_jsonStart(); try { if (!$this->_checkCsrf()) return; if (!$this->_requireAuth()) return; $perms = $this->loadPerms($_SESSION['username'] ?? ''); if (!$this->isAdmin($perms)) { $this->_jsonOut(['error'=>'Admin only'], 403); return; } $data = $this->_readJsonBody(); if (!$data) { $this->_jsonOut(["error" => "Invalid input"], 400); return; } $filesToDelete = []; if (!empty($data['deleteAll'])) { $trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; $shareFile = $trashDir . "trash.json"; if (file_exists($shareFile)) { $tmp = json_decode(file_get_contents($shareFile), true); if (is_array($tmp)) { foreach ($tmp as $item) { if (!empty($item['trashName'])) $filesToDelete[] = $item['trashName']; } } } } elseif (isset($data['files']) && is_array($data['files'])) { $filesToDelete = $data['files']; } else { $this->_jsonOut(["error" => "No trash file identifiers provided"], 400); return; } $result = FileModel::deleteTrashFiles($filesToDelete); if (!empty($result['deleted'])) { $msg = "Trash item".(count($result['deleted']) === 1 ? "" : "s")." deleted: ".implode(", ", $result['deleted']); $this->_jsonOut(["success"=>$msg]); } elseif (!empty($result['error'])) { $this->_jsonOut(["error"=>$result['error']], 400); } else { $this->_jsonOut(["success"=>"No items to delete."]); } } catch (Throwable $e) { error_log('FileController::deleteTrashFiles error: '.$e->getMessage()); $this->_jsonOut(['error' => 'Internal server error while deleting trash files.'], 500); } finally { $this->_jsonEnd(); } } public function getFileTags(): void { header('Content-Type: application/json; charset=utf-8'); $tags = FileModel::getFileTags(); echo json_encode($tags); exit; } public function saveFileTag(): void { $this->_jsonStart(); try { if (!$this->_checkCsrf()) return; if (!$this->_requireAuth()) return; $data = $this->_readJsonBody(); if (!$data) { $this->_jsonOut(["error" => "No data received"], 400); return; } $file = trim((string)($data['file'] ?? '')); $folder = $this->_normalizeFolder($data['folder'] ?? 'root'); $tags = $data['tags'] ?? []; $deleteGlobal= !empty($data['deleteGlobal']); $tagToDelete = isset($data['tagToDelete']) ? trim((string)$data['tagToDelete']) : null; if ($file === '' || !$this->_validFile($file)) { $this->_jsonOut(["error"=>"Invalid file."], 400); return; } if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 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; } // Folder scope: write $dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write'); if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } // Ownership unless admin/folder-owner $ignoreOwnership = $this->isAdmin($userPermissions) || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)) || ACL::isOwner($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions); if (!$ignoreOwnership) { $meta = $this->loadFolderMetadata($folder); if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) { $this->_jsonOut(["error" => "Forbidden: you are not the owner of this file."], 403); return; } } $result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete); $this->_jsonOut($result); } catch (Throwable $e) { error_log('FileController::saveFileTag error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); $this->_jsonOut(['error' => 'Internal server error while saving tags.'], 500); } finally { $this->_jsonEnd(); } } public function getFileList(): void { 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; } // ---- 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); $ownOnlyGrant = ACL::hasGrant($username, $folder, 'read_own'); if (!$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) { http_response_code(500); echo json_encode(['error' => 'File model failed.']); return; } if (!is_array($result)) { throw new RuntimeException('FileModel::getFileList returned a non-array.'); } if (isset($result['error'])) { http_response_code(400); 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 (is_array($files) && array_keys($files) !== range(0, count($files) - 1)) { $filtered = []; foreach ($files as $name => $meta) { if (isset($meta['uploader']) && strcasecmp((string)$meta['uploader'], $username) === 0) { $filtered[$name] = $meta; } } $result['files'] = $filtered; } // If files are a numeric array of metadata else if (is_array($files)) { $result['files'] = array_values(array_filter( $files, function ($f) use ($username) { return isset($f['uploader']) && strcasecmp((string)$f['uploader'], $username) === 0; } )); } } 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()); http_response_code(500); echo json_encode(['error' => 'Internal server error while listing files.']); } finally { restore_error_handler(); } } public function getShareLinks() { header('Content-Type: application/json'); $shareFile = FileModel::getAllShareLinks(); echo json_encode($shareFile, JSON_PRETTY_PRINT); } public function getAllShareLinks(): void { header('Content-Type: application/json'); $shareFile = META_DIR . 'share_links.json'; $links = file_exists($shareFile) ? json_decode(file_get_contents($shareFile), true) ?? [] : []; $now = time(); $cleaned = []; foreach ($links as $token => $record) { if (!empty($record['expires']) && $record['expires'] < $now) continue; $cleaned[$token] = $record; } if (count($cleaned) !== count($links)) { file_put_contents($shareFile, json_encode($cleaned, JSON_PRETTY_PRINT)); } echo json_encode($cleaned); } public function deleteShareLink() { header('Content-Type: application/json'); $token = $_POST['token'] ?? ''; if (!$token) { echo json_encode(['success' => false, 'error' => 'No token provided']); return; } $deleted = FileModel::deleteShareLink($token); echo json_encode($deleted ? ['success' => true] : ['success' => false, 'error' => 'Not found']); } public function createFile(): void { $this->_jsonStart(); try { if (!$this->_requireAuth()) return; $body = $this->_readJsonBody(); $folder = $this->_normalizeFolder($body['folder'] ?? 'root'); $filename = basename(trim((string)($body['name'] ?? ''))); if (!$this->_validFolder($folder)) { $this->_jsonOut(["error" => "Invalid folder name."], 400); return; } if (!$this->_validFile($filename)) { $this->_jsonOut(["error" => "Invalid file name."], 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; } // Folder scope: write $dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write'); if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } $result = FileModel::createFile($folder, $filename, $username); if (empty($result['success'])) { $this->_jsonOut(['success'=>false,'error'=>$result['error'] ?? 'Failed to create file'], $result['code'] ?? 400); return; } $this->_jsonOut(['success'=>true]); } catch (Throwable $e) { error_log('FileController::createFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); $this->_jsonOut(['error' => 'Internal server error while creating file.'], 500); } finally { $this->_jsonEnd(); } } }