diff --git a/build-plugin.sh b/build-plugin.sh index cf862e2..bcbf832 100755 --- a/build-plugin.sh +++ b/build-plugin.sh @@ -2,7 +2,7 @@ set -eu PLUGIN="u-navigator" -VERSION="2026.06.23.r040" +VERSION="2026.06.23.r041" PKG_DIR="packages" WORK_DIR=".plugin-build" PKG_NAME="${PLUGIN}-${VERSION}.tgz" diff --git a/packages/u-navigator-2026.06.23.r040.tgz b/packages/u-navigator-2026.06.23.r040.tgz deleted file mode 100644 index 299a637..0000000 Binary files a/packages/u-navigator-2026.06.23.r040.tgz and /dev/null differ diff --git a/packages/u-navigator-2026.06.23.r041.tgz b/packages/u-navigator-2026.06.23.r041.tgz new file mode 100644 index 0000000..4be5f6b Binary files /dev/null and b/packages/u-navigator-2026.06.23.r041.tgz differ diff --git a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/download.php b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/download.php new file mode 100644 index 0000000..6d2416b --- /dev/null +++ b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/download.php @@ -0,0 +1,122 @@ +open($zipFile, ZipArchive::OVERWRITE) !== true) { + @unlink($zipFile); + unav_download_error(500, 'Could not create ZIP archive'); +} + +$usedNames = []; +foreach ($items as $item) { + $baseName = unav_zip_unique_name(unav_zip_safe_name(basename($item['path'])), $usedNames); + unav_zip_add_path($zip, $item['path'], $baseName); +} +$zip->close(); + +$downloadName = count($items) === 1 ? unav_zip_safe_name(basename($items[0]['path'])) . '.zip' : 'u-navigator-download.zip'; +unav_stream_file($zipFile, $downloadName, true); + +function unav_download_paths(): array { + if (isset($_GET['paths'])) { + $decoded = json_decode((string)$_GET['paths'], true); + if (!is_array($decoded)) { + unav_download_error(400, 'Invalid paths'); + } + $paths = array_values(array_filter($decoded, fn($item) => is_string($item) && $item !== '')); + } else { + $paths = [$_GET['path'] ?? '']; + } + if (!$paths) { + unav_download_error(400, 'Missing path'); + } + return array_slice($paths, 0, 200); +} + +function unav_stream_file(string $path, string $name, bool $deleteAfter = false): void { + if (!is_file($path) || !is_readable($path)) { + if ($deleteAfter) { + @unlink($path); + } + unav_download_error(404, 'Download file not found'); + } + header('Content-Type: application/octet-stream'); + header('Content-Length: ' . filesize($path)); + header('Content-Disposition: attachment; filename="' . addcslashes($name, '"\\') . '"'); + header('X-Content-Type-Options: nosniff'); + readfile($path); + if ($deleteAfter) { + @unlink($path); + } + exit; +} + +function unav_zip_add_path(ZipArchive $zip, string $path, string $zipPath): void { + if (is_link($path)) { + return; + } + $real = realpath($path); + if ($real === false) { + return; + } + unav_root_for_real($real); + + if (is_file($real)) { + $zip->addFile($real, $zipPath); + return; + } + if (!is_dir($real)) { + return; + } + $zip->addEmptyDir($zipPath); + foreach (scandir($real) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + unav_zip_add_path($zip, $real . '/' . $entry, $zipPath . '/' . unav_zip_safe_name($entry)); + } +} + +function unav_zip_safe_name(string $name): string { + $safe = trim(str_replace(["\0", '/', '\\'], '-', $name)); + return $safe === '' || $safe === '.' || $safe === '..' ? 'download' : $safe; +} + +function unav_zip_unique_name(string $name, array &$used): string { + $candidate = $name; + $index = 2; + while (isset($used[$candidate])) { + $candidate = $name . ' ' . $index; + $index++; + } + $used[$candidate] = true; + return $candidate; +} + +function unav_download_error(int $status, string $message): void { + http_response_code($status); + header('Content-Type: text/plain; charset=utf-8'); + echo $message; + exit; +} diff --git a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/upload.php b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/upload.php index f22f4e5..cf81f2a 100644 --- a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/upload.php +++ b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/upload.php @@ -12,11 +12,22 @@ foreach ($_FILES['files']['name'] ?? [] as $index => $name) { if ($_FILES['files']['error'][$index] !== UPLOAD_ERR_OK) { unav_error(400, 'Upload failed'); } - $safeName = basename($name); + $safeName = unav_upload_safe_relative_path((string)$name); + $relativeParent = dirname($safeName); + if ($relativeParent !== '.') { + $parentCandidate = $target['path'] . '/' . $relativeParent; + unav_root_for_real($target['path']); + if (!is_dir($parentCandidate) && !mkdir($parentCandidate, 0777, true) && !is_dir($parentCandidate)) { + unav_error(500, 'Could not create upload directory'); + } + } $destination = unav_destination_path($target['path'] . '/' . $safeName); if (file_exists($destination['path'])) { unav_error(409, 'Destination already exists'); } + if (!is_dir(dirname($destination['path']))) { + unav_error(500, 'Could not create upload directory'); + } if (!move_uploaded_file($_FILES['files']['tmp_name'][$index], $destination['path'])) { unav_error(500, 'Could not store uploaded file'); } @@ -27,3 +38,18 @@ foreach ($_FILES['files']['name'] ?? [] as $index => $name) { } unav_json(['uploaded' => $uploaded], 201); + +function unav_upload_safe_relative_path(string $name): string { + $parts = []; + foreach (preg_split('#[/\\\\]+#', $name) ?: [] as $part) { + $part = trim($part); + if ($part === '' || $part === '.' || $part === '..') { + continue; + } + $parts[] = str_replace("\0", '', $part); + } + if (!$parts) { + unav_error(400, 'Invalid upload filename'); + } + return implode('/', $parts); +} diff --git a/server/public/app.js b/server/public/app.js index ee89678..fc659e7 100644 --- a/server/public/app.js +++ b/server/public/app.js @@ -270,6 +270,10 @@ function renderExplorer(body, win) { + + + +
@@ -287,6 +291,19 @@ function renderExplorer(body, win) { body.querySelector('[data-action="up"]').addEventListener('click', () => loadExplorer(win, parentPath(data.path))); body.querySelector('[data-action="view-list"]').addEventListener('click', () => setExplorerView(win, 'list')); body.querySelector('[data-action="view-icons"]').addEventListener('click', () => setExplorerView(win, 'icons')); + body.querySelector('[data-action="upload-files"]').addEventListener('click', () => body.querySelector('[data-upload="files"]').click()); + body.querySelector('[data-action="upload-folder"]').addEventListener('click', () => body.querySelector('[data-upload="folder"]').click()); + for (const inputEl of body.querySelectorAll('.upload-input')) { + inputEl.addEventListener('change', async () => { + try { + await uploadFileList(win, [...inputEl.files], data.path); + } catch (error) { + showError(error); + } finally { + inputEl.value = ''; + } + }); + } const dropZone = body.querySelector('.drop-zone'); dropZone.addEventListener('dragenter', (event) => { @@ -872,19 +889,28 @@ async function handleDrop(event, win, targetPath) { const files = getDroppedFiles(event); if (files.length) { - const form = new FormData(); - for (const file of files) { - form.append('files', file, file.name); - } - appendCsrf(form); - await fetchChecked(apiUrl(`upload.php?path=${encodeURIComponent(targetPath)}`), { - method: 'POST', - body: form - }); - await loadExplorer(win, targetPath); + await uploadFileList(win, files, targetPath); } } +async function uploadFileList(win, files, targetPath) { + if (isReadOnly()) { + showError(new Error('Read-only mode ist aktiv.')); + return; + } + if (!files.length) return; + const form = new FormData(); + for (const file of files) { + form.append('files', file, file.webkitRelativePath || file.name); + } + appendCsrf(form); + await fetchChecked(apiUrl(`upload.php?path=${encodeURIComponent(targetPath)}`), { + method: 'POST', + body: form + }); + await loadExplorer(win, targetPath); +} + async function createJob(type, source, destination = null, refreshWindowIds = [], extra = {}) { if (['move', 'copy', 'permissions'].includes(type) && isReadOnly()) { showError(new Error('Read-only mode ist aktiv.')); @@ -1034,6 +1060,25 @@ function openPermissionsDialog(win, entry) { render(); } +function downloadEntries(win, entry = null) { + const entries = entry && selectedPathSet(win).has(entry.path) ? selectedEntries(win) : (entry ? [entry] : [{ + path: win.data.path, + name: basename(win.data.path), + type: 'directory' + }]); + const paths = entries.map((item) => item.path); + const query = paths.length === 1 + ? `path=${encodeURIComponent(paths[0])}` + : `paths=${encodeURIComponent(JSON.stringify(paths))}`; + recordDebug('download.start', { paths }); + const link = document.createElement('a'); + link.href = apiUrl(`download.php?${query}`); + link.download = ''; + document.body.appendChild(link); + link.click(); + link.remove(); +} + function renderPermissionsDialog() { const dialog = state.permissionsDialog; const overlay = document.createElement('div'); @@ -1250,6 +1295,7 @@ function renderContextMenu() { actions.push(['Vorschau', () => openPreview(entry, win.id)]); } actions.push(['Eigenschaften', () => showPropertiesForEntry(win, entry)]); + actions.push(['Herunterladen', () => downloadEntries(win, entry)]); if (!isReadOnly()) { actions.push(['Umbenennen', () => renameEntry(win, entry)]); actions.push(['Berechtigungen...', () => openPermissionsDialog(win, entry)]); @@ -1257,6 +1303,7 @@ function renderContextMenu() { actions.push(['Verschieben nach...', () => promptTransfer(win, entry, 'move')]); } } else { + actions.push(['Aktuellen Ordner herunterladen', () => downloadEntries(win, null)]); actions.push(['Neu laden', () => loadExplorer(win, win.data.path)]); } @@ -1886,6 +1933,11 @@ function dirname(value) { return `/${parts.slice(0, -1).join('/')}`; } +function basename(value) { + const parts = String(value || '').split('/').filter(Boolean); + return parts[parts.length - 1] || ''; +} + function extensionOf(name) { const value = String(name || ''); const index = value.lastIndexOf('.'); diff --git a/server/public/styles.css b/server/public/styles.css index e0676b2..d68c0e8 100644 --- a/server/public/styles.css +++ b/server/public/styles.css @@ -186,7 +186,7 @@ margin-bottom: 10px; } -.u-nav .pathbar input { +.u-nav .pathbar input:not(.upload-input) { background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; @@ -209,6 +209,10 @@ flex: 0 0 auto; } +.u-nav .upload-input { + display: none; +} + .u-nav .drop-zone { border: 1px dashed transparent; border-radius: 8px; diff --git a/u-navigator.plg b/u-navigator.plg index b946acc..8f63d42 100644 --- a/u-navigator.plg +++ b/u-navigator.plg @@ -1,7 +1,7 @@ - + @@ -24,7 +24,7 @@ https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package; -7c89a55e074a1345cf67197cfcdb54c7 +381ba21ef4664561c9a69ddada5b5b15