Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 142 additions & 3 deletions src/FileRise/Http/Controllers/FileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()) {
Expand All @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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'])) {
Expand Down
79 changes: 78 additions & 1 deletion src/FileRise/Http/Controllers/FolderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()) {
Expand All @@ -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';
Expand Down Expand Up @@ -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 */
}
}

Expand Down
61 changes: 55 additions & 6 deletions src/FileRise/Support/WorkerLauncher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down