diff --git a/src/FileRise/Http/Controllers/FileController.php b/src/FileRise/Http/Controllers/FileController.php index 2871cf9..1c9b4f2 100644 --- a/src/FileRise/Http/Controllers/FileController.php +++ b/src/FileRise/Http/Controllers/FileController.php @@ -563,6 +563,88 @@ private function isAsyncRequested(array $payload): bool || $this->truthy($payload['queue'] ?? false) || $this->truthy($payload['asyncJob'] ?? false); } + + /** + * Execute a transfer job synchronously in-request as a last resort, + * when no worker (background or foreground) can be spawned. + * Updates the job file to 'done' or 'error' and returns a job-envelope. + * + * @return array{ok:bool,jobId?:string,status?:string,error?:string} + */ + private function runJobInRequest(string $jobId, array $jobSpec): array + { + $kind = strtolower((string)($jobSpec['kind'] ?? '')); + $mode = strtolower((string)($jobSpec['mode'] ?? '')); + $sourceFolder = (string)($jobSpec['sourceFolder'] ?? ''); + $destinationFolder = (string)($jobSpec['destinationFolder'] ?? ''); + $files = (array)($jobSpec['files'] ?? []); + $crossSource = !empty($jobSpec['crossSource']); + $sourceId = (string)($jobSpec['sourceId'] ?? ''); + $destSourceId = (string)($jobSpec['destSourceId'] ?? ''); + + try { + if ($kind === 'file_move' || $kind === 'file_copy') { + if ($crossSource) { + $result = $kind === 'file_copy' + ? FileModel::copyFilesAcrossSources($sourceId, $destSourceId, $sourceFolder, $destinationFolder, $files) + : FileModel::moveFilesAcrossSources($sourceId, $destSourceId, $sourceFolder, $destinationFolder, $files); + } elseif ($kind === 'file_copy') { + $result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files); + } else { + $result = FileModel::moveFiles($sourceFolder, $destinationFolder, $files); + } + } elseif ($kind === 'folder_move' || $kind === 'folder_copy') { + // FolderModel has no simple move/copy entry point; route through + // FolderController which already handles ACLs and all edge cases. + $payload = ['source' => $sourceFolder, 'destination' => $destinationFolder, + 'mode' => $mode === 'copy' ? 'copy' : 'move']; + if ($sourceId !== '') { $payload['sourceId'] = $sourceId; } + if ($destSourceId !== '') { $payload['destSourceId'] = $destSourceId; } + ob_start(); + $fc = new FolderController(); + $fc->setJsonBodyOverride($payload); + $fc->moveFolder(); + $raw = ob_get_clean(); + $result = json_decode((string)$raw, true) ?: []; + } else { + return ['ok' => false, 'error' => 'Unsupported job kind for in-request execution.']; + } + } catch (\Throwable $e) { + $job = TransferJobManager::load($jobId) ?: []; + $job['status'] = 'error'; + $job['phase'] = 'error'; + $job['error'] = $e->getMessage(); + $job['endedAt'] = time(); + TransferJobManager::save($jobId, $job); + return ['ok' => false, 'error' => $e->getMessage()]; + } + + if (isset($result['error'])) { + $job = TransferJobManager::load($jobId) ?: []; + $job['status'] = 'error'; + $job['phase'] = 'error'; + $job['error'] = (string)$result['error']; + $job['endedAt'] = time(); + TransferJobManager::save($jobId, $job); + return ['ok' => false, 'error' => (string)$result['error']]; + } + + $job = TransferJobManager::load($jobId) ?: []; + $job['status'] = 'done'; + $job['phase'] = 'done'; + $job['pct'] = 100; + $job['endedAt'] = time(); + $job['error'] = null; + TransferJobManager::save($jobId, $job); + + return [ + 'ok' => true, + 'jobId' => $jobId, + 'status' => 'done', + 'statusUrl' => '/api/file/transferJobStatus.php?jobId=' . urlencode($jobId), + ]; + } + private function enqueueTransferJob(array $jobSpec): array { try { @@ -610,6 +692,22 @@ private function enqueueTransferJob(array $jobSpec): array ]; } + // If no shell execution is available at all, skip the spawn attempt + // entirely - spawnWorker() would hang waiting for a CLI that can't run. + if (!WorkerLauncher::canSpawnBackground() && !WorkerLauncher::canRunForeground()) { + $syncResult = $this->runJobInRequest($jobId, $jobSpec); + if (!empty($syncResult['ok'])) { + return $syncResult; + } + $job = TransferJobManager::load($jobId) ?: []; + $job['status'] = 'error'; + $job['phase'] = 'error'; + $job['error'] = (string)($syncResult['error'] ?? 'In-request execution failed'); + $job['endedAt'] = time(); + TransferJobManager::save($jobId, $job); + return ['error' => $job['error']]; + } + $spawn = TransferJobManager::spawnWorker($jobId); if (empty($spawn['ok'])) { if (WorkerLauncher::allowsForegroundFallback() && TransferJobManager::canRunWorkerForeground()) { @@ -625,6 +723,11 @@ private function enqueueTransferJob(array $jobSpec): array } } + $syncResult = $this->runJobInRequest($jobId, $jobSpec); + if (!empty($syncResult['ok'])) { + return $syncResult; + } + $job = TransferJobManager::load($jobId) ?: []; $job['status'] = 'error'; $job['phase'] = 'error'; @@ -3122,8 +3225,11 @@ public function videoThumbnail() $thumbPath = $thumbDir . ($isPdf ? 'pthumb_' : 'vthumb_') . $hash . '.jpg'; if (!is_file($thumbPath) || @filesize($thumbPath) === 0) { - if (!function_exists('exec')) { - $fail(501, "Thumbnail generator unavailable."); + if (!WorkerLauncher::canRunForeground()) { + // exec unavailable - return 204 No Content so the browser + // silently skips the thumbnail without showing an error. + http_response_code(204); + return; } @session_write_close(); @@ -3703,7 +3809,40 @@ public function downloadZip() return; } - // Robust spawn (detect php CLI, log, record PID) with shared-hosting fallback. + // If no exec is available, fall back to synchronous ZipArchive for zip format. + // 7z requires exec and cannot be supported in this environment. + if (!WorkerLauncher::canSpawnBackground() && !WorkerLauncher::canRunForeground()) { + if ($format !== 'zip') { + $job['status'] = 'error'; + $job['error'] = 'Archive format not supported: exec() is unavailable on this host.'; + @file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX); + $this->jsonOut(["error" => "Archive format '$format' is not supported on this host (exec disabled). Use ZIP instead."], 501); + return; + } + + // ZIP: run synchronously via ZipArchive (pure PHP, no exec needed) + $zipResult = FileModel::createZipArchive($folder, $files); + if (isset($zipResult['error'])) { + $job['status'] = 'error'; + $job['error'] = $zipResult['error']; + @file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX); + $this->jsonOut(["error" => $zipResult['error']], 500); + return; + } + + $job['status'] = 'done'; + $job['zipPath'] = $zipResult['zipPath']; + @file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX); + $this->jsonOut([ + 'ok' => true, + 'token' => $token, + 'status' => 'done', + 'statusUrl' => '/api/file/zipStatus.php?k=' . urlencode($token), + 'downloadUrl' => '/api/file/downloadZipFile.php?k=' . urlencode($token), + ]); + return; + } + if (WorkerLauncher::prefersSync() && WorkerLauncher::allowsForegroundFallback()) { $run = $this->runZipWorkerForeground($token, $tokFile, $logDir, $activeSourceId); if (empty($run['ok'])) { diff --git a/src/FileRise/Http/Controllers/FolderController.php b/src/FileRise/Http/Controllers/FolderController.php index 5335d1a..d09c56d 100644 --- a/src/FileRise/Http/Controllers/FolderController.php +++ b/src/FileRise/Http/Controllers/FolderController.php @@ -660,6 +660,62 @@ private function isAsyncRequested(array $payload): bool || $this->truthy($payload['asyncJob'] ?? false); } + /** + * Execute a folder transfer job synchronously in-request. + * Routes through moveFolder() which handles all ACLs and edge cases. + * + * @return array{ok:bool,jobId?:string,status?:string,error?:string} + */ + private function runJobInRequest(string $jobId, array $jobSpec): array + { + $kind = strtolower((string)($jobSpec['kind'] ?? '')); + $mode = strtolower((string)($jobSpec['mode'] ?? ($kind === 'folder_copy' ? 'copy' : 'move'))); + $source = (string)($jobSpec['sourceFolder'] ?? ''); + $destination = (string)($jobSpec['destinationFolder'] ?? ''); + $sourceId = (string)($jobSpec['sourceId'] ?? ''); + $destSourceId = (string)($jobSpec['destSourceId'] ?? ''); + + $payload = [ + 'source' => $source, + 'destination' => $destination, + 'mode' => $mode, + ]; + if ($sourceId !== '') { $payload['sourceId'] = $sourceId; } + if ($destSourceId !== '') { $payload['destSourceId'] = $destSourceId; } + + ob_start(); + $this->setJsonBodyOverride($payload); + $this->moveFolder(); + $this->setJsonBodyOverride(null); + $raw = ob_get_clean(); + $result = json_decode((string)$raw, true) ?: []; + + if (isset($result['error'])) { + $job = TransferJobManager::load($jobId) ?: []; + $job['status'] = 'error'; + $job['phase'] = 'error'; + $job['error'] = (string)$result['error']; + $job['endedAt'] = time(); + TransferJobManager::save($jobId, $job); + return ['ok' => false, 'error' => (string)$result['error']]; + } + + $job = TransferJobManager::load($jobId) ?: []; + $job['status'] = 'done'; + $job['phase'] = 'done'; + $job['pct'] = 100; + $job['endedAt'] = time(); + $job['error'] = null; + TransferJobManager::save($jobId, $job); + + return [ + 'ok' => true, + 'jobId' => $jobId, + 'status' => 'done', + 'statusUrl' => '/api/file/transferJobStatus.php?jobId=' . urlencode($jobId), + ]; + } + private function enqueueTransferJob(array $jobSpec): array { try { @@ -707,6 +763,22 @@ private function enqueueTransferJob(array $jobSpec): array ]; } + // If no shell execution is available at all, skip the spawn attempt + // entirely - spawnWorker() would hang waiting for a CLI that can't run. + if (!WorkerLauncher::canSpawnBackground() && !WorkerLauncher::canRunForeground()) { + $syncResult = $this->runJobInRequest($jobId, $jobSpec); + if (!empty($syncResult['ok'])) { + return $syncResult; + } + $job = TransferJobManager::load($jobId) ?: []; + $job['status'] = 'error'; + $job['phase'] = 'error'; + $job['error'] = (string)($syncResult['error'] ?? 'In-request execution failed'); + $job['endedAt'] = time(); + TransferJobManager::save($jobId, $job); + return ['error' => $job['error']]; + } + $spawn = TransferJobManager::spawnWorker($jobId); if (empty($spawn['ok'])) { if (WorkerLauncher::allowsForegroundFallback() && TransferJobManager::canRunWorkerForeground()) { @@ -722,6 +794,11 @@ private function enqueueTransferJob(array $jobSpec): array } } + $syncResult = $this->runJobInRequest($jobId, $jobSpec); + if (!empty($syncResult['ok'])) { + return $syncResult; + } + $job = TransferJobManager::load($jobId) ?: []; $job['status'] = 'error'; $job['phase'] = 'error'; @@ -3368,7 +3445,7 @@ public function moveFolder(): void echo json_encode(['error' => 'Source and destination must have the same owner']); return; } - } catch (\Throwable $e) { /* ignore – fall through */ + } catch (\Throwable $e) { /* ignore - fall through */ } } diff --git a/src/FileRise/Support/WorkerLauncher.php b/src/FileRise/Support/WorkerLauncher.php index 57e84a1..b941c10 100644 --- a/src/FileRise/Support/WorkerLauncher.php +++ b/src/FileRise/Support/WorkerLauncher.php @@ -40,7 +40,10 @@ public static function isFunctionEnabled(string $name): bool public static function hasShell(): bool { - return is_file('/bin/sh') && is_executable('/bin/sh'); + // Suppress open_basedir warnings - /bin/sh is outside the web root + // on many shared hosts. A warning here would throw an ErrorException + // and bubble up through enqueueTransferJob's catch block. + return @is_file('/bin/sh') && @is_executable('/bin/sh'); } public static function canSpawnBackground(): bool @@ -49,12 +52,36 @@ public static function canSpawnBackground(): bool return false; } - return self::isFunctionEnabled('shell_exec') || self::isFunctionEnabled('exec'); + if (!self::isFunctionEnabled('shell_exec') && !self::isFunctionEnabled('exec')) { + return false; + } + + if (defined('FR_EXEC_PROBE') && FR_EXEC_PROBE === false) { + return false; + } + + return self::resolvePhpCli() !== null; } public static function canRunForeground(): bool { - return self::isFunctionEnabled('exec'); + // In sync mode we never want to exec anything - skip probing entirely. + if (self::prefersSync()) { + return false; + } + + if (!self::isFunctionEnabled('exec')) { + return false; + } + + // FR_EXEC_PROBE=false lets operators signal that exec() is available + // per disable_functions but broken at the OS/sandbox level (e.g. hangs + // due to open_basedir or seccomp), so we must not call it at all. + if (defined('FR_EXEC_PROBE') && FR_EXEC_PROBE === false) { + return false; + } + + return self::resolvePhpCli() !== null; } /** @@ -84,20 +111,42 @@ public static function allowsForegroundFallback(): bool public static function resolvePhpCli(): ?string { + // If exec probing is disabled, we must not attempt to locate or verify + // any PHP CLI binary - the caller treats exec as unavailable entirely. + if (defined('FR_EXEC_PROBE') && FR_EXEC_PROBE === false) { + return null; + } + + // php-cgi is not a CLI binary - it blocks waiting for stdin. + // Derive a cli candidate from PHP_BINARY if it looks like php-cgi. + $rawBinary = PHP_BINARY ?: ''; + $derivedCli = ''; + if (str_contains($rawBinary, 'php-cgi')) { + $derivedCli = str_replace('php-cgi', 'php', $rawBinary); + } + $candidates = array_values(array_filter([ - PHP_BINARY ?: null, + str_contains($rawBinary, 'php-cgi') ? null : ($rawBinary ?: null), + $derivedCli ?: null, '/usr/local/bin/php', '/usr/bin/php', '/bin/php', ])); + $canExec = self::isFunctionEnabled('exec') + && !(defined('FR_EXEC_PROBE') && FR_EXEC_PROBE === false) + && !self::prefersSync(); + $canShellExec = self::isFunctionEnabled('shell_exec') + && !(defined('FR_EXEC_PROBE') && FR_EXEC_PROBE === false) + && !self::prefersSync(); + foreach ($candidates as $bin) { $bin = (string)$bin; if ($bin === '') { continue; } - if (self::isFunctionEnabled('exec')) { + if ($canExec) { $rc = 1; $out = []; @exec(escapeshellcmd($bin) . ' -v >/dev/null 2>&1', $out, $rc); @@ -107,7 +156,7 @@ public static function resolvePhpCli(): ?string continue; } - if (self::isFunctionEnabled('shell_exec')) { + if ($canShellExec) { $out = @shell_exec(escapeshellcmd($bin) . ' -v 2>/dev/null'); if (is_string($out) && trim($out) !== '') { return $bin;