Add upload buttons and ZIP downloads
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
PLUGIN="u-navigator"
|
PLUGIN="u-navigator"
|
||||||
VERSION="2026.06.23.r040"
|
VERSION="2026.06.23.r041"
|
||||||
PKG_DIR="packages"
|
PKG_DIR="packages"
|
||||||
WORK_DIR=".plugin-build"
|
WORK_DIR=".plugin-build"
|
||||||
PKG_NAME="${PLUGIN}-${VERSION}.tgz"
|
PKG_NAME="${PLUGIN}-${VERSION}.tgz"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/common.php';
|
||||||
|
|
||||||
|
$paths = unav_download_paths();
|
||||||
|
$items = [];
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
$items[] = unav_existing_path($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($items) === 1 && is_file($items[0]['path']) && !is_link($items[0]['path'])) {
|
||||||
|
unav_stream_file($items[0]['path'], basename($items[0]['path']));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists('ZipArchive')) {
|
||||||
|
unav_download_error(500, 'ZIP support is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
$zipPath = tempnam(sys_get_temp_dir(), 'u-nav-download-');
|
||||||
|
if ($zipPath === false) {
|
||||||
|
unav_download_error(500, 'Could not create temporary archive');
|
||||||
|
}
|
||||||
|
$zipFile = $zipPath . '.zip';
|
||||||
|
rename($zipPath, $zipFile);
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->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;
|
||||||
|
}
|
||||||
@@ -12,11 +12,22 @@ foreach ($_FILES['files']['name'] ?? [] as $index => $name) {
|
|||||||
if ($_FILES['files']['error'][$index] !== UPLOAD_ERR_OK) {
|
if ($_FILES['files']['error'][$index] !== UPLOAD_ERR_OK) {
|
||||||
unav_error(400, 'Upload failed');
|
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);
|
$destination = unav_destination_path($target['path'] . '/' . $safeName);
|
||||||
if (file_exists($destination['path'])) {
|
if (file_exists($destination['path'])) {
|
||||||
unav_error(409, 'Destination already exists');
|
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'])) {
|
if (!move_uploaded_file($_FILES['files']['tmp_name'][$index], $destination['path'])) {
|
||||||
unav_error(500, 'Could not store uploaded file');
|
unav_error(500, 'Could not store uploaded file');
|
||||||
}
|
}
|
||||||
@@ -27,3 +38,18 @@ foreach ($_FILES['files']['name'] ?? [] as $index => $name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unav_json(['uploaded' => $uploaded], 201);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+54
-2
@@ -270,6 +270,10 @@ function renderExplorer(body, win) {
|
|||||||
<input value="${escapeAttr(data.path || '')}" aria-label="Pfad">
|
<input value="${escapeAttr(data.path || '')}" aria-label="Pfad">
|
||||||
<button class="icon" data-action="go" title="Öffnen" aria-label="Öffnen">↵</button>
|
<button class="icon" data-action="go" title="Öffnen" aria-label="Öffnen">↵</button>
|
||||||
<button class="icon" data-action="refresh" title="Neu laden" aria-label="Neu laden">↻</button>
|
<button class="icon" data-action="refresh" title="Neu laden" aria-label="Neu laden">↻</button>
|
||||||
|
<button class="icon" data-action="upload-files" title="Dateien hochladen" aria-label="Dateien hochladen" ${isReadOnly() ? 'disabled' : ''}>⤴</button>
|
||||||
|
<button class="icon" data-action="upload-folder" title="Ordner hochladen" aria-label="Ordner hochladen" ${isReadOnly() ? 'disabled' : ''}>⇪</button>
|
||||||
|
<input class="upload-input" data-upload="files" type="file" multiple>
|
||||||
|
<input class="upload-input" data-upload="folder" type="file" multiple webkitdirectory directory>
|
||||||
<div class="view-toggle" aria-label="Ansicht">
|
<div class="view-toggle" aria-label="Ansicht">
|
||||||
<button class="icon ${data.view !== 'icons' ? 'active' : ''}" data-action="view-list" title="Listenansicht" aria-label="Listenansicht">☰</button>
|
<button class="icon ${data.view !== 'icons' ? 'active' : ''}" data-action="view-list" title="Listenansicht" aria-label="Listenansicht">☰</button>
|
||||||
<button class="icon ${data.view === 'icons' ? 'active' : ''}" data-action="view-icons" title="Symbolansicht" aria-label="Symbolansicht">▦</button>
|
<button class="icon ${data.view === 'icons' ? 'active' : ''}" data-action="view-icons" title="Symbolansicht" aria-label="Symbolansicht">▦</button>
|
||||||
@@ -287,6 +291,19 @@ function renderExplorer(body, win) {
|
|||||||
body.querySelector('[data-action="up"]').addEventListener('click', () => loadExplorer(win, parentPath(data.path)));
|
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-list"]').addEventListener('click', () => setExplorerView(win, 'list'));
|
||||||
body.querySelector('[data-action="view-icons"]').addEventListener('click', () => setExplorerView(win, 'icons'));
|
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');
|
const dropZone = body.querySelector('.drop-zone');
|
||||||
dropZone.addEventListener('dragenter', (event) => {
|
dropZone.addEventListener('dragenter', (event) => {
|
||||||
@@ -872,9 +889,19 @@ async function handleDrop(event, win, targetPath) {
|
|||||||
|
|
||||||
const files = getDroppedFiles(event);
|
const files = getDroppedFiles(event);
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
|
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();
|
const form = new FormData();
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
form.append('files', file, file.name);
|
form.append('files', file, file.webkitRelativePath || file.name);
|
||||||
}
|
}
|
||||||
appendCsrf(form);
|
appendCsrf(form);
|
||||||
await fetchChecked(apiUrl(`upload.php?path=${encodeURIComponent(targetPath)}`), {
|
await fetchChecked(apiUrl(`upload.php?path=${encodeURIComponent(targetPath)}`), {
|
||||||
@@ -882,7 +909,6 @@ async function handleDrop(event, win, targetPath) {
|
|||||||
body: form
|
body: form
|
||||||
});
|
});
|
||||||
await loadExplorer(win, targetPath);
|
await loadExplorer(win, targetPath);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createJob(type, source, destination = null, refreshWindowIds = [], extra = {}) {
|
async function createJob(type, source, destination = null, refreshWindowIds = [], extra = {}) {
|
||||||
@@ -1034,6 +1060,25 @@ function openPermissionsDialog(win, entry) {
|
|||||||
render();
|
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() {
|
function renderPermissionsDialog() {
|
||||||
const dialog = state.permissionsDialog;
|
const dialog = state.permissionsDialog;
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
@@ -1250,6 +1295,7 @@ function renderContextMenu() {
|
|||||||
actions.push(['Vorschau', () => openPreview(entry, win.id)]);
|
actions.push(['Vorschau', () => openPreview(entry, win.id)]);
|
||||||
}
|
}
|
||||||
actions.push(['Eigenschaften', () => showPropertiesForEntry(win, entry)]);
|
actions.push(['Eigenschaften', () => showPropertiesForEntry(win, entry)]);
|
||||||
|
actions.push(['Herunterladen', () => downloadEntries(win, entry)]);
|
||||||
if (!isReadOnly()) {
|
if (!isReadOnly()) {
|
||||||
actions.push(['Umbenennen', () => renameEntry(win, entry)]);
|
actions.push(['Umbenennen', () => renameEntry(win, entry)]);
|
||||||
actions.push(['Berechtigungen...', () => openPermissionsDialog(win, entry)]);
|
actions.push(['Berechtigungen...', () => openPermissionsDialog(win, entry)]);
|
||||||
@@ -1257,6 +1303,7 @@ function renderContextMenu() {
|
|||||||
actions.push(['Verschieben nach...', () => promptTransfer(win, entry, 'move')]);
|
actions.push(['Verschieben nach...', () => promptTransfer(win, entry, 'move')]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
actions.push(['Aktuellen Ordner herunterladen', () => downloadEntries(win, null)]);
|
||||||
actions.push(['Neu laden', () => loadExplorer(win, win.data.path)]);
|
actions.push(['Neu laden', () => loadExplorer(win, win.data.path)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1886,6 +1933,11 @@ function dirname(value) {
|
|||||||
return `/${parts.slice(0, -1).join('/')}`;
|
return `/${parts.slice(0, -1).join('/')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function basename(value) {
|
||||||
|
const parts = String(value || '').split('/').filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
function extensionOf(name) {
|
function extensionOf(name) {
|
||||||
const value = String(name || '');
|
const value = String(name || '');
|
||||||
const index = value.lastIndexOf('.');
|
const index = value.lastIndexOf('.');
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.u-nav .pathbar input {
|
.u-nav .pathbar input:not(.upload-input) {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -209,6 +209,10 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.u-nav .upload-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.u-nav .drop-zone {
|
.u-nav .drop-zone {
|
||||||
border: 1px dashed transparent;
|
border: 1px dashed transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "u-navigator">
|
<!ENTITY name "u-navigator">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.23.r040">
|
<!ENTITY version "2026.06.23.r041">
|
||||||
<!ENTITY package "&name;-&version;.tgz">
|
<!ENTITY package "&name;-&version;.tgz">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg">
|
||||||
<!ENTITY support "https://git.casaderoll.de/michael/Unraid-Navigator">
|
<!ENTITY support "https://git.casaderoll.de/michael/Unraid-Navigator">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/&package;">
|
<FILE Name="/boot/config/plugins/&name;/&package;">
|
||||||
<URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL>
|
<URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL>
|
||||||
<MD5>7c89a55e074a1345cf67197cfcdb54c7</MD5>
|
<MD5>381ba21ef4664561c9a69ddada5b5b15</MD5>
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Run="/bin/bash">
|
<FILE Run="/bin/bash">
|
||||||
|
|||||||
Reference in New Issue
Block a user