diff --git a/build-plugin.sh b/build-plugin.sh index fb32826..13200c8 100755 --- a/build-plugin.sh +++ b/build-plugin.sh @@ -2,7 +2,7 @@ set -eu PLUGIN="u-navigator" -VERSION="2026.06.23.r003" +VERSION="2026.06.23.r004" PKG_DIR="packages" WORK_DIR=".plugin-build" PKG_NAME="${PLUGIN}-${VERSION}.tgz" diff --git a/packages/u-navigator-2026.06.23.r003.tgz b/packages/u-navigator-2026.06.23.r003.tgz deleted file mode 100644 index 589c2de..0000000 Binary files a/packages/u-navigator-2026.06.23.r003.tgz and /dev/null differ diff --git a/packages/u-navigator-2026.06.23.r004.tgz b/packages/u-navigator-2026.06.23.r004.tgz new file mode 100644 index 0000000..d729826 Binary files /dev/null and b/packages/u-navigator-2026.06.23.r004.tgz differ diff --git a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/common.php b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/common.php index 53b86b3..afe2d8d 100644 --- a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/common.php +++ b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/common.php @@ -1,6 +1,7 @@ $_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); diff --git a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/job_worker.php b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/job_worker.php new file mode 100644 index 0000000..cdda58f --- /dev/null +++ b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/job_worker.php @@ -0,0 +1,119 @@ +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'); + } + } +} diff --git a/server/public/app.js b/server/public/app.js index ef9df02..6880894 100644 --- a/server/public/app.js +++ b/server/public/app.js @@ -209,7 +209,7 @@ function renderExplorer(body, win) { dropZone.addEventListener('dragover', (event) => { if (hasDropPayload(event)) { event.preventDefault(); - setDropEffect(event, event.altKey ? 'copy' : 'move'); + setDropEffect(event, 'copy'); data.dragOver = true; dropZone.classList.add('drag-over'); } @@ -235,6 +235,9 @@ function renderExplorer(body, win) { for (const row of body.querySelectorAll('.file-row')) { const entry = data.entries.find((item) => item.path === row.dataset.path); + row.draggable = false; + row.addEventListener('dragstart', (event) => event.preventDefault()); + row.addEventListener('selectstart', (event) => event.preventDefault()); row.addEventListener('pointerdown', (event) => { lockExplorerScroll(win, body); startItemPointerDrag(event, win, entry); @@ -334,18 +337,6 @@ async function handleDrop(event, win, targetPath) { return; } - const internalPath = state.draggedEntry?.path || ''; - if (internalPath) { - const name = internalPath.split('/').filter(Boolean).at(-1); - const destination = `${targetPath.replace(/\/$/, '')}/${name}`; - if (internalPath === destination) { - throw new Error('Quelle und Ziel sind identisch.'); - } - await createJob(event.altKey ? 'copy' : 'move', internalPath, destination); - await reloadExplorerWindows([win.id, state.draggedEntry?.sourceWindowId].filter(Boolean)); - return; - } - const files = getDroppedFiles(event); if (files.length) { const form = new FormData(); @@ -378,7 +369,8 @@ async function reloadExplorerWindows(ids) { } function hasDropPayload(event) { - return Boolean(state.draggedEntry || getDroppedFiles(event).length); + const types = [...(event.dataTransfer?.types || [])]; + return types.includes('Files') || getDroppedFiles(event).length > 0; } function showError(error) { @@ -546,7 +538,7 @@ function updatePropertiesWindows() { } function startItemPointerDrag(event, win, entry) { - if (event.button !== 0) return; + if (event.button !== 0 || !entry) return; state.pointerDrag = { sourceWindowId: win.id, @@ -563,6 +555,9 @@ function startItemPointerDrag(event, win, entry) { const up = (upEvent) => finishItemPointerDrag(upEvent, move, up); document.addEventListener('pointermove', move); document.addEventListener('pointerup', up, { once: true }); + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch {} } function updateItemPointerDrag(event) { @@ -597,6 +592,7 @@ async function finishItemPointerDrag(event, move, up) { return; } + event.preventDefault(); state.suppressClickPath = drag.path; const target = findPointerDropTarget(event); if (!target) { diff --git a/server/public/styles.css b/server/public/styles.css index 635ed91..eee438f 100644 --- a/server/public/styles.css +++ b/server/public/styles.css @@ -225,6 +225,7 @@ background: var(--surface); color: var(--text); cursor: default; + -webkit-user-drag: none; } .u-nav .file-row:hover, @@ -242,6 +243,7 @@ } .u-nav .file-row { + -webkit-user-drag: none; user-select: none; } @@ -257,7 +259,9 @@ align-items: center; display: flex; gap: 8px; + -webkit-user-drag: none; min-width: 150px; + user-select: none; } .u-nav .badge { @@ -269,6 +273,8 @@ justify-content: center; min-width: 58px; padding: 2px 8px; + -webkit-user-drag: none; + user-select: none; } .u-nav .context-menu { diff --git a/u-navigator.plg b/u-navigator.plg index 7f371a8..0b08b0f 100644 --- a/u-navigator.plg +++ b/u-navigator.plg @@ -1,7 +1,7 @@ - + ]> @@ -14,7 +14,7 @@ https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package; -c1ad74583e3a83dae808677aceb76d79 +da98c5f068d4f12dfddb0e7143a7aaa0