Use background jobs for file transfers

This commit is contained in:
Mikei386
2026-06-23 06:52:25 +02:00
parent 11b76f4572
commit 3fec83cf73
9 changed files with 201 additions and 47 deletions
@@ -1,6 +1,7 @@
<?php
const UNAV_DEFAULT_ROOT = '/mnt/user';
const UNAV_JOB_DIR = '/tmp/u-navigator/jobs';
function unav_roots(): array {
$configured = getenv('U_NAV_ROOTS') ?: UNAV_DEFAULT_ROOT;
@@ -152,3 +153,35 @@ function unav_guard_destination(string $source, string $destination): void {
unav_error(400, 'Destination cannot be inside source');
}
}
function unav_job_dir(): string {
if (!is_dir(UNAV_JOB_DIR) && !mkdir(UNAV_JOB_DIR, 0777, true) && !is_dir(UNAV_JOB_DIR)) {
unav_error(500, 'Could not create job directory');
}
return UNAV_JOB_DIR;
}
function unav_job_path(string $id): string {
if (!preg_match('/^[A-Za-z0-9._-]+$/', $id)) {
unav_error(400, 'Invalid job id');
}
return unav_job_dir() . '/' . $id . '.json';
}
function unav_job_read(string $id): array {
$path = unav_job_path($id);
if (!is_file($path)) {
unav_error(404, 'Job not found');
}
$job = json_decode(file_get_contents($path) ?: '{}', true);
if (!is_array($job)) {
unav_error(500, 'Invalid job state');
}
return $job;
}
function unav_job_write(array $job): void {
$id = $job['id'] ?? '';
$job['updatedAt'] = date(DATE_ATOM);
file_put_contents(unav_job_path($id), json_encode($job, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOCK_EX);
}
@@ -1,20 +1,15 @@
<?php
require_once __DIR__ . '/common.php';
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
unav_json(unav_job_read($_GET['id'] ?? ''));
}
unav_assert_writable();
$action = $_GET['action'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
unav_json([
'id' => $_GET['id'] ?? '0',
'type' => 'unknown',
'status' => 'completed',
'progress' => 100,
'message' => 'Completed',
'error' => null,
'createdAt' => date(DATE_ATOM),
'updatedAt' => date(DATE_ATOM),
]);
if (!in_array($action, ['move', 'copy'], true)) {
unav_error(404, 'Unknown job action');
}
$data = unav_request_json();
@@ -22,24 +17,29 @@ $source = unav_existing_path($data['source'] ?? null);
$destination = unav_destination_path($data['destination'] ?? null);
unav_guard_destination($source['path'], $destination['path']);
if ($action === 'move') {
if (!rename($source['path'], $destination['path'])) {
unav_copy_recursive($source['path'], $destination['path']);
unav_delete_recursive($source['path']);
}
} elseif ($action === 'copy') {
unav_copy_recursive($source['path'], $destination['path']);
$id = str_replace('.', '', uniqid($action . '-', true));
$now = date(DATE_ATOM);
$job = [
'id' => $id,
'type' => $action,
'status' => 'queued',
'progress' => 0,
'message' => 'Queued',
'error' => null,
'source' => $source['path'],
'destination' => $destination['path'],
'createdAt' => $now,
'updatedAt' => $now,
];
unav_job_write($job);
$worker = __DIR__ . '/job_worker.php';
$php = is_executable('/usr/bin/php') ? '/usr/bin/php' : (PHP_BINARY ?: 'php');
if (function_exists('exec') && is_file($worker)) {
exec(escapeshellarg($php) . ' ' . escapeshellarg($worker) . ' ' . escapeshellarg($id) . ' > /dev/null 2>&1 &');
} else {
unav_error(404, 'Unknown job action');
require $worker;
unav_run_job($id);
}
unav_json([
'id' => (string)time(),
'type' => $action,
'status' => 'completed',
'progress' => 100,
'message' => 'Completed',
'error' => null,
'createdAt' => date(DATE_ATOM),
'updatedAt' => date(DATE_ATOM),
], 202);
unav_json($job, 202);
@@ -0,0 +1,119 @@
<?php
require_once __DIR__ . '/common.php';
if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
unav_run_job($argv[1] ?? '');
}
function unav_run_job(string $id): void {
$job = unav_job_read($id);
try {
unav_worker_update($job, 'running', 5, 'Preparing');
unav_worker_guard($job['source'], $job['destination']);
if ($job['type'] === 'move') {
unav_worker_move($job['source'], $job['destination'], $job);
} elseif ($job['type'] === 'copy') {
unav_worker_copy($job['source'], $job['destination'], $job);
} else {
throw new RuntimeException('Unknown job action');
}
unav_worker_update($job, 'completed', 100, 'Completed');
} catch (Throwable $error) {
$job['error'] = $error->getMessage();
unav_worker_update($job, 'failed', 100, $error->getMessage());
}
}
function unav_worker_update(array &$job, string $status, int $progress, string $message): void {
$job['status'] = $status;
$job['progress'] = $progress;
$job['message'] = $message;
unav_job_write($job);
}
function unav_worker_guard(string $source, string $destination): void {
$sourceReal = realpath($source);
if ($sourceReal === false) {
throw new RuntimeException('Source not found');
}
unav_worker_assert_inside_root($sourceReal);
$parentReal = realpath(dirname($destination));
if ($parentReal === false) {
throw new RuntimeException('Destination parent not found');
}
unav_worker_assert_inside_root($parentReal);
$destinationPath = $parentReal . '/' . basename($destination);
if (file_exists($destinationPath)) {
throw new RuntimeException('Destination already exists');
}
if ($sourceReal === $destinationPath || str_starts_with($destinationPath, rtrim($sourceReal, '/') . '/')) {
throw new RuntimeException('Destination cannot be inside source');
}
}
function unav_worker_assert_inside_root(string $path): void {
foreach (unav_roots() as $root) {
if (unav_is_inside($path, $root['path'])) {
return;
}
}
throw new RuntimeException('Path is outside allowed roots');
}
function unav_worker_move(string $source, string $destination, array &$job): void {
unav_worker_update($job, 'running', 15, 'Moving');
if (@rename($source, $destination)) {
return;
}
unav_worker_copy($source, $destination, $job);
unav_worker_delete($source);
}
function unav_worker_copy(string $source, string $destination, array &$job): void {
if (is_link($source)) {
throw new RuntimeException('Copying symlinks is not allowed');
}
if (is_dir($source)) {
if (!mkdir($destination, 0777, true) && !is_dir($destination)) {
throw new RuntimeException('Could not create destination directory');
}
$entries = array_values(array_filter(scandir($source) ?: [], fn($entry) => $entry !== '.' && $entry !== '..'));
$total = max(count($entries), 1);
foreach ($entries as $index => $entry) {
unav_worker_copy($source . '/' . $entry, $destination . '/' . $entry, $job);
unav_worker_update($job, 'running', min(95, 10 + (int)((($index + 1) / $total) * 80)), 'Copying directory');
}
return;
}
if (!is_file($source)) {
throw new RuntimeException('Only files and directories can be copied');
}
unav_worker_update($job, 'running', 35, 'Copying file');
if (!copy($source, $destination)) {
throw new RuntimeException('Copy failed');
}
}
function unav_worker_delete(string $path): void {
if (is_link($path) || is_file($path)) {
if (!unlink($path)) {
throw new RuntimeException('Could not remove source after move');
}
return;
}
if (is_dir($path)) {
foreach (scandir($path) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
unav_worker_delete($path . '/' . $entry);
}
if (!rmdir($path)) {
throw new RuntimeException('Could not remove source directory after move');
}
}
}