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 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. */ 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; // 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."; } private function spawnZipWorker(string $token, string $tokFile, string $logDir): array { $worker = realpath(PROJECT_ROOT . '/src/cli/zip_worker.php'); if (!$worker || !is_file($worker)) { return ['ok'=>false, 'error'=>'zip_worker.php not found']; } // Find a PHP CLI binary that actually works $candidates = array_values(array_filter([ PHP_BINARY ?: null, '/usr/local/bin/php', '/usr/bin/php', '/bin/php' ])); $php = null; foreach ($candidates as $bin) { if (!$bin) continue; $rc = 1; @exec(escapeshellcmd($bin).' -v >/dev/null 2>&1', $o, $rc); if ($rc === 0) { $php = $bin; break; } } if (!$php) { return ['ok'=>false, 'error'=>'No working php CLI found']; } $logFile = $logDir . DIRECTORY_SEPARATOR . 'WORKER-' . $token . '.log'; // Ensure TMPDIR is on the same FS as the final zip; actually apply it to the child process. $tmpDir = rtrim((string)META_DIR, '/\\') . '/ziptmp'; @mkdir($tmpDir, 0775, true); // Build one sh -c string so env + nohup + echo $! are in the same shell $cmdStr = 'export TMPDIR=' . escapeshellarg($tmpDir) . ' ; ' . 'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) . ' ' . escapeshellarg($token) . ' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!'; $pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr)); $pid = is_string($pid) ? (int)trim($pid) : 0; // Persist spawn metadata into token (best-effort) $job = json_decode((string)@file_get_contents($tokFile), true) ?: []; $job['spawn'] = [ 'ts' => time(), 'php' => $php, 'pid' => $pid, 'log' => $logFile ]; @file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX); return $pid > 0 ? ['ok'=>true] : ['ok'=>false, 'error'=>'spawn returned no PID']; } // --- 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 = array_values(array_filter(array_map('basename', (array)$data['files']))); 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; } $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; } } // 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(); } } public function deleteFiles() { $this->_jsonStart(); try { if (!$this->_checkCsrf()) return; if (!$this->_requireAuth()) return; $data = $this->_readJsonBody(); if (!is_array($data) || !isset($data['files']) || !is_array($data['files'])) { $this->_jsonOut(["error" => "No file names provided"], 400); 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; } $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); // --- 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; } // --- Folder-scope check (granular) ---------------------------------- $dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'delete'); if ($dv) { $this->_jsonOut(["error" => $dv], 403); 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); // 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; } } // --- Perform delete -------------------------------------------------- $result = FileModel::deleteFiles($folder, $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; } $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; } $hasSourceDelete = ACL::canDelete($username, $userPermissions, $sourceFolder) || $this->ownsFolderOrAncestor($sourceFolder, $username, $userPermissions); if (!$hasSourceDelete) { $this->_jsonOut(["error" => "Forbidden: no delete permission on source"], 403); return; } // 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 && 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; } } // --- 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(); } } 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 granular rename (or ancestor-owner) if (!(ACL::canRename($username, $userPermissions, $folder))) { $this->_jsonOut(["error"=>"Forbidden: no rename rights"], 403); return; } // 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)); $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::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, 'edit'); 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(); } } /** * Stream a file with proper HTTP Range support so HTML5 video/audio can seek. * * @param string $fullPath Absolute filesystem path * @param string $downloadName Name shown in Content-Disposition * @param string $mimeType MIME type (from FileModel::getDownloadInfo) * @param bool $inline true => inline, false => attachment */ private function streamFileWithRange(string $fullPath, string $downloadName, string $mimeType, bool $inline): void { if (!is_file($fullPath) || !is_readable($fullPath)) { http_response_code(404); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['error' => 'File not found']); exit; } $size = (int)@filesize($fullPath); $start = 0; $end = $size > 0 ? $size - 1 : 0; if ($size < 0) { $size = 0; $end = 0; } // Close session + disable output buffering for streaming if (session_status() === PHP_SESSION_ACTIVE) { @session_write_close(); } if (function_exists('apache_setenv')) { @apache_setenv('no-gzip', '1'); } @ini_set('zlib.output_compression', '0'); @ini_set('output_buffering', 'off'); while (ob_get_level() > 0) { @ob_end_clean(); } $disposition = $inline ? 'inline' : 'attachment'; $mime = $mimeType ?: 'application/octet-stream'; header('X-Content-Type-Options: nosniff'); header('Accept-Ranges: bytes'); header("Content-Type: {$mime}"); header("Content-Disposition: {$disposition}; filename=\"" . basename($downloadName) . "\""); // Handle HTTP Range header (single range) $length = $size; if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=\s*(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)) { if ($m[1] !== '') { $start = (int)$m[1]; } if ($m[2] !== '') { $end = (int)$m[2]; } // clamp to file size if ($start < 0) $start = 0; if ($end < $start) $end = $start; if ($end >= $size) $end = $size - 1; $length = $end - $start + 1; http_response_code(206); header("Content-Range: bytes {$start}-{$end}/{$size}"); header("Content-Length: {$length}"); } else { // no range => full file http_response_code(200); if ($size > 0) { header("Content-Length: {$size}"); } } $fp = @fopen($fullPath, 'rb'); if ($fp === false) { http_response_code(500); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['error' => 'Unable to open file.']); exit; } if ($start > 0) { @fseek($fp, $start); } $bytesToSend = $length; $chunkSize = 8192; while ($bytesToSend > 0 && !feof($fp)) { $readSize = ($bytesToSend > $chunkSize) ? $chunkSize : $bytesToSend; $buffer = fread($fp, $readSize); if ($buffer === false) { break; } echo $buffer; flush(); $bytesToSend -= strlen($buffer); if (connection_aborted()) { break; } } fclose($fp); exit; } public function downloadFile() { if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); header('Content-Type: application/json; charset=utf-8'); echo json_encode(["error" => "Unauthorized"]); exit; } $file = isset($_GET['file']) ? basename((string)$_GET['file']) : ''; $folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root'; $inlineParam = isset($_GET['inline']) && (string)$_GET['inline'] === '1'; if (!preg_match(REGEX_FILE_NAME, $file)) { http_response_code(400); header('Content-Type: application/json; charset=utf-8'); echo json_encode(["error" => "Invalid file name."]); exit; } if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { http_response_code(400); header('Content-Type: application/json; charset=utf-8'); 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); header('Content-Type: application/json; charset=utf-8'); 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); header('Content-Type: application/json; charset=utf-8'); 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); header('Content-Type: application/json; charset=utf-8'); echo json_encode(["error" => $downloadInfo['error']]); exit; } $realFilePath = $downloadInfo['filePath']; $mimeType = $downloadInfo['mimeType']; // Decide inline vs attachment: // - if ?inline=1 => always inline (used by filePreview.js) // - else keep your old behavior: images inline, everything else attachment $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); $inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico']; $inline = $inlineParam || in_array($ext, $inlineImageTypes, true); // Stream with proper Range support for video/audio seeking $this->streamFileWithRange($realFilePath, basename($realFilePath), $mimeType, $inline); } public function zipStatus() { if (!$this->_requireAuth()) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(["error"=>"Unauthorized"]); return; } $username = $_SESSION['username'] ?? ''; $token = isset($_GET['k']) ? preg_replace('/[^a-f0-9]/','',(string)$_GET['k']) : ''; if ($token === '' || strlen($token) < 8) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error"=>"Bad token"]); return; } $tokFile = rtrim((string)META_DIR, '/\\') . '/ziptmp/.tokens/' . $token . '.json'; if (!is_file($tokFile)) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(["error"=>"Not found"]); return; } $job = json_decode((string)@file_get_contents($tokFile), true) ?: []; if (($job['user'] ?? '') !== $username) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(["error"=>"Forbidden"]); return; } $ready = (($job['status'] ?? '') === 'done') && !empty($job['zipPath']) && is_file($job['zipPath']); $out = [ 'status' => $job['status'] ?? 'unknown', 'error' => $job['error'] ?? null, 'ready' => $ready, // progress (if present) 'pct' => $job['pct'] ?? null, 'filesDone' => $job['filesDone'] ?? null, 'filesTotal' => $job['filesTotal'] ?? null, 'bytesDone' => $job['bytesDone'] ?? null, 'bytesTotal' => $job['bytesTotal'] ?? null, 'current' => $job['current'] ?? null, 'phase' => $job['phase'] ?? null, // timing (always include for UI) 'startedAt' => $job['startedAt'] ?? null, 'finalizeAt' => $job['finalizeAt'] ?? null, ]; if ($ready) { $out['size'] = @filesize($job['zipPath']) ?: null; $out['downloadUrl'] = '/api/file/downloadZipFile.php?k=' . urlencode($token); } header('Content-Type: application/json'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); echo json_encode($out); } public function downloadZipFile() { if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); echo "Unauthorized"; return; } $username = $_SESSION['username'] ?? ''; $token = isset($_GET['k']) ? preg_replace('/[^a-f0-9]/','',(string)$_GET['k']) : ''; if ($token === '' || strlen($token) < 8) { http_response_code(400); echo "Bad token"; return; } $tokFile = rtrim((string)META_DIR, '/\\') . '/ziptmp/.tokens/' . $token . '.json'; if (!is_file($tokFile)) { http_response_code(404); echo "Not found"; return; } $job = json_decode((string)@file_get_contents($tokFile), true) ?: []; @unlink($tokFile); // one-shot token if (($job['user'] ?? '') !== $username) { http_response_code(403); echo "Forbidden"; return; } $zip = (string)($job['zipPath'] ?? ''); $zipReal = realpath($zip); $root = realpath(rtrim((string)META_DIR, '/\\') . '/ziptmp'); if (!$zipReal || !$root || strpos($zipReal, $root) !== 0 || !is_file($zipReal)) { http_response_code(404); echo "Not found"; return; } @session_write_close(); @set_time_limit(0); @ignore_user_abort(true); if (function_exists('apache_setenv')) @apache_setenv('no-gzip','1'); @ini_set('zlib.output_compression','0'); @ini_set('output_buffering','off'); while (ob_get_level()>0) @ob_end_clean(); @clearstatcache(true, $zipReal); $name = isset($_GET['name']) ? preg_replace('/[^A-Za-z0-9._-]/','_', (string)$_GET['name']) : 'files.zip'; if ($name === '' || str_ends_with($name,'.')) $name = 'files.zip'; $size = (int)@filesize($zipReal); header('X-Accel-Buffering: no'); header('X-Content-Type-Options: nosniff'); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="'.$name.'"'); if ($size>0) header('Content-Length: '.$size); header('Cache-Control: no-store, no-cache, must-revalidate'); header('Pragma: no-cache'); readfile($zipReal); @unlink($zipReal); } public function downloadZip() { try { if (!$this->_checkCsrf()) { $this->_jsonOut(["error"=>"Bad CSRF"],400); return; } if (!$this->_requireAuth()) { $this->_jsonOut(["error"=>"Unauthorized"],401); 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) && !empty($perms['disableZip'])) { $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; } } } $root = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp'; $tokDir = $root . DIRECTORY_SEPARATOR . '.tokens'; $logDir = $root . DIRECTORY_SEPARATOR . '.logs'; if (!is_dir($tokDir)) @mkdir($tokDir, 0700, true); if (!is_dir($logDir)) @mkdir($logDir, 0700, true); @chmod($tokDir, 0700); @chmod($logDir, 0700); if (!is_dir($tokDir) || !is_writable($tokDir)) { $this->_jsonOut(["error"=>"ZIP token dir not writable."],500); return; } // Light janitor: purge old tokens/logs > 6h (best-effort) $now = time(); foreach ((glob($tokDir . DIRECTORY_SEPARATOR . '*.json') ?: []) as $tf) { if (is_file($tf) && ($now - (int)@filemtime($tf)) > 21600) { @unlink($tf); } } foreach ((glob($logDir . DIRECTORY_SEPARATOR . 'WORKER-*.log') ?: []) as $lf) { if (is_file($lf) && ($now - (int)@filemtime($lf)) > 21600) { @unlink($lf); } } // Per-user and global caps (simple anti-DoS) $perUserCap = 2; // tweak if desired $globalCap = 8; // tweak if desired $tokens = glob($tokDir . DIRECTORY_SEPARATOR . '*.json') ?: []; $mine = 0; $all = 0; foreach ($tokens as $tf) { $job = json_decode((string)@file_get_contents($tf), true) ?: []; $st = $job['status'] ?? 'unknown'; if ($st === 'queued' || $st === 'working' || $st === 'finalizing') { $all++; if (($job['user'] ?? '') === $username) $mine++; } } if ($mine >= $perUserCap) { $this->_jsonOut(["error"=>"You already have ZIP jobs running. Try again shortly."], 429); return; } if ($all >= $globalCap) { $this->_jsonOut(["error"=>"ZIP queue is busy. Try again shortly."], 429); return; } // Create job token $token = bin2hex(random_bytes(16)); $tokFile = $tokDir . DIRECTORY_SEPARATOR . $token . '.json'; $job = [ 'user' => $username, 'folder' => $folder, 'files' => array_values($files), 'status' => 'queued', 'ctime' => time(), 'startedAt' => null, 'finalizeAt' => null, 'zipPath' => null, 'error' => null ]; if (file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX) === false) { $this->_jsonOut(["error"=>"Failed to create zip job."],500); return; } // Robust spawn (detect php CLI, log, record PID) $spawn = $this->spawnZipWorker($token, $tokFile, $logDir); if (!$spawn['ok']) { $job['status'] = 'error'; $job['error'] = 'Spawn failed: '.$spawn['error']; @file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX); $this->_jsonOut(["error"=>"Failed to enqueue ZIP: ".$spawn['error']], 500); return; } $this->_jsonOut([ 'ok' => true, 'token' => $token, 'status' => 'queued', 'statusUrl' => '/api/file/zipStatus.php?k=' . urlencode($token), 'downloadUrl' => '/api/file/downloadZipFile.php?k=' . urlencode($token) ]); } catch (Throwable $e) { error_log('FileController::downloadZip enqueue error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); $this->_jsonOut(['error' => 'Internal error while queuing ZIP.'], 500); } } 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::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, 'extract'); 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"); ?>