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 []; } 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 ($this->isFolderOnly($userPermissions) && !$this->isAdmin($userPermissions)) { $folder = trim($folder); if ($folder !== '' && strtolower($folder) !== 'root') { if ($folder !== $username && strpos($folder, $username . '/') !== 0) { return "Forbidden: folder scope violation."; } } } if ($ignoreOwnership) return null; $metadata = $this->loadFolderMetadata($folder); foreach ($files as $f) { $name = basename((string)$f); if (!isset($metadata[$name]['uploader']) || strcasecmp($metadata[$name]['uploader'], $username) !== 0) { return "Forbidden: you are not the owner of '{$name}'."; } } return null; } private function enforceFolderScope(string $folder, string $username, array $userPermissions): ?string { if ($this->isAdmin($userPermissions)) return null; if (!$this->isFolderOnly($userPermissions)) return null; $f = trim($folder); while ($f !== '' && strtolower($f) !== 'root') { if (FolderModel::getOwnerFor($f) === $username) return null; $pos = strrpos($f, '/'); $f = $pos === false ? '' : substr($f, 0, $pos); } return "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); // ACL: require read on source and write on destination (or write on both if your ACL only has canWrite) if (!ACL::canRead($username, $userPermissions, $sourceFolder)) { $this->_jsonOut(["error"=>"Forbidden: no read access to source"], 403); return; } if (!ACL::canWrite($username, $userPermissions, $destinationFolder)) { $this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return; } // scope/ownership $violation = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions); if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } $dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions); if ($dv) { $this->_jsonOut(["error"=>$dv], 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); if (!ACL::canWrite($username, $userPermissions, $folder)) { $this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return; } $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 source and destination to be safe if (!ACL::canWrite($username, $userPermissions, $sourceFolder)) { $this->_jsonOut(["error"=>"Forbidden: no write access to source"], 403); return; } if (!ACL::canWrite($username, $userPermissions, $destinationFolder)) { $this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return; } $violation = $this->enforceScopeAndOwnership($sourceFolder, $data['files'], $username, $userPermissions); if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; } $dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions); if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } $result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['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); if (!ACL::canWrite($username, $userPermissions, $folder)) { $this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return; } $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); if (!ACL::canWrite($username, $userPermissions, $folder)) { $this->_jsonOut(["error"=>"Forbidden: no write access"], 403); return; } $dv = $this->enforceFolderScope($folder, $username, $userPermissions); if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; } // If overwriting, enforce ownership for non-admins $baseDir = rtrim(UPLOAD_DIR, '/\\'); $dir = ($folder === 'root') ? $baseDir : $baseDir . DIRECTORY_SEPARATOR . $folder; $path = $dir . DIRECTORY_SEPARATOR . $fileName; if (is_file($path)) { $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)); // Folder-level view grants $fullView = $ignoreOwnership || ACL::canRead($username, $perms, $folder); $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)); $fullView = $ignoreOwnership || ACL::canRead($username, $perms, $folder); $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 if (!ACL::canWrite($username, $perms, $folder)) { $this->_jsonOut(["error"=>"Forbidden: no write access to destination"], 403); return; } $dv = $this->enforceFolderScope($folder, $username, $perms); 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"); ?>