commit 37ddea08f4b79a72dbdb4046c5690b4bcd93137c Author: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon Jun 22 22:11:54 2026 +0200 Initial U-Navigator prototype diff --git a/README.md b/README.md new file mode 100644 index 0000000..d107177 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# U-Navigator + +Unraid File Navigator. + +Ziel: Eine optische Dateiverwaltung fuer Unraid, inspiriert von Synology File Station. + +Aktueller Stand: + +- Machbarkeitsstudie: [docs/MACHBARKEITSSTUDIE.md](docs/MACHBARKEITSSTUDIE.md) +- Prototyp: In-App-Fenstermanager, Explorer-Fenster, Eigenschaften-Fenster, Transfer-Queue, lokaler Upload und interne Move/Copy-Jobs. + +Kurzentscheidung: + +U-Navigator ist als Unraid-Plugin machbar. Empfohlen wird ein hybrider Aufbau aus Unraid-Plugin, lokalem Backend-Dienst und moderner Weboberflaeche. + +Naechster sinnvoller Schritt: + +Unraid-Plugin-Paketierung fuer die bestehende Web-App und das Backend. + +## Lokal starten + +Der Prototyp nutzt standardmaessig `/mnt/user`. Auf Entwicklungsmaschinen ohne diesen Pfad kann ein Root gesetzt werden: + +```sh +U_NAV_ROOT="$PWD" PORT=8088 npm start +``` + +Danach im Browser oeffnen: + +```text +http://127.0.0.1:8088 +``` + +## Tests + +```sh +npm test +``` + +Die Tests starten einen lokalen HTTP-Server und pruefen Listing, Upload, Move-Jobs, Zielkonflikte und Pfad-Sandboxing. diff --git a/docs/MACHBARKEITSSTUDIE.md b/docs/MACHBARKEITSSTUDIE.md new file mode 100644 index 0000000..3f372c4 --- /dev/null +++ b/docs/MACHBARKEITSSTUDIE.md @@ -0,0 +1,338 @@ +# U-Navigator - Machbarkeitsstudie + +Stand: 2026-06-22 + +## Kurzfazit + +U-Navigator ist machbar. + +Ein Unraid-Plugin kann eine optische Dateiverwaltung direkt im Unraid-WebGUI bereitstellen. Der sinnvollste Ansatz ist aber nicht, die komplette Synology File Station als reines PHP-Plugin nachzubauen. Besser ist eine kleine Unraid-Integration, die eine moderne Web-App einbettet und einen klar begrenzten lokalen Backend-Dienst für Dateioperationen nutzt. + +Empfohlene Zielarchitektur: + +- Unraid-Plugin fuer Installation, WebGUI-Menueintrag, Konfiguration und Rechte-/Pfadgrenzen. +- Lokaler Backend-Dienst fuer Dateioperationen, Suche, Upload/Download, Vorschau und lange Jobs. +- Frontend als moderne Single Page App im Unraid-WebGUI. +- Zugriff standardmaessig nur auf erlaubte Unraid-Pfade wie `/mnt/user`, optional `/mnt/disk*`, `/mnt/cache`, `/mnt/poolname`. + +## Was Unraid dafuer hergibt + +Unraid laesst sich durch Plugins erweitern. Existierende reale Plugins zeigen das Muster: + +- Plugin-Definition als `.plg`-Datei. +- Plugin-Dateien werden unter `/usr/local/emhttp/plugins/` installiert. +- Persistente Plugin-Konfiguration liegt typischerweise unter `/boot/config/plugins/`. +- WebGUI-Erweiterungen bestehen in der Praxis aus PHP, JavaScript, CSS, Shell-Skripten und paketierten Archivdateien. + +Beispiele aus bestehenden Projekten: + +- `community.applications` enthaelt ein Plugin-Verzeichnis unter `source/.../usr/local/emhttp/plugins/community.applications` und ist ueberwiegend PHP/JavaScript/HTML. +- `unassigned.devices` installiert Dateien nach `/usr/local/emhttp/plugins`, nutzt Shell-Skripte, Mountpoints und WebGUI-Seiten und zeigt, dass komplexere Storage-nahe Unraid-Plugins realistisch sind. +- `dynamix` ist bzw. war die WebGUI-Basisfamilie von Unraid; das alte Repository verweist auf die heutige Codebasis unter `github.com/unraid/dynamix`. + +## Funktionsumfang nach Risikostufen + +### MVP - realistisch + +Ziel: Sicherer Dateibrowser fuer Unraid-Shares. + +- Baumansicht fuer Shares und Ordner. +- Tabellen-/Listenansicht mit Name, Typ, Groesse, Datum, Besitzer/Rechten. +- Navigation in `/mnt/user`. +- Suchen nach Dateiname. +- Download einzelner Dateien. +- Upload in aktuellem Ordner. +- Ordner anlegen. +- Umbenennen. +- Kopieren/Verschieben innerhalb erlaubter Pfade. +- Loeschen mit Sicherheitsabfrage. +- Text-/Bildvorschau fuer einfache Dateitypen. +- Konfigurierbare erlaubte Root-Pfade. + +Das ist technisch gut machbar. + +### Erweiterter Umfang - machbar, aber mehr Aufwand + +- Mehrfachauswahl mit Batch-Aktionen. +- Fortschrittsanzeige fuer lange Kopier-/Verschiebe-Jobs. +- Hintergrundjobs mit Queue. +- Archiv erstellen/entpacken. +- Medienvorschau fuer Video/Audio. +- Dateirechte bearbeiten. +- Owner/Group anzeigen oder aendern. +- Share-uebergreifende Suche mit Index. +- Papierkorb oder Undo-Logik. +- Drag and Drop Upload. +- Kontextmenues wie in Synology File Station. + +Das ist machbar, braucht aber einen sauberen Job-Runner und robuste Fehlerbehandlung. + +### Kritischer Umfang - nur mit klaren Grenzen + +- Root-Dateisystem durchsuchen. +- Systemdateien bearbeiten. +- Dateien als root beliebig schreiben/loeschen. +- Direkte Bearbeitung von Docker-/VM-Images. +- Rekursive Massenoperationen ohne Quota, Dry-Run oder Abbruchmechanik. +- Oeffentliche Webfreigaben oder externe Links. + +Diese Funktionen sind gefaehrlich und sollten nicht in den ersten Versionen enthalten sein. + +## Architekturvorschlag + +### Variante A: Reines Unraid-PHP-Plugin + +Beschreibung: +Die Dateioperationen laufen direkt ueber PHP-Seiten und Shell-Kommandos im Unraid-WebGUI. + +Vorteile: + +- Nahe am klassischen Unraid-Plugin-Modell. +- Wenig Runtime-Abhaengigkeiten. +- Einfaches Installationspaket. + +Nachteile: + +- Moderne UI und lange Jobs werden schnell unhandlich. +- Shell-Aufrufe muessen extrem sauber abgesichert werden. +- Uploads, Streaming, Suche und Fortschritt sind schwieriger wartbar. +- PHP-Versionen und Unraid-WebGUI-Aenderungen koennen brechen. + +Bewertung: +Machbar fuer einen einfachen Dateibrowser. Nicht ideal fuer eine File-Station-artige Anwendung. + +### Variante B: Plugin + lokaler Backend-Dienst + +Beschreibung: +Das Plugin installiert die WebGUI-Integration und startet einen lokalen Dienst. Das Frontend spricht diesen Dienst ueber HTTP oder Unix-Socket an. + +Backend-Optionen: + +- Go: sehr gut fuer statische Binary, Dateisystemoperationen, Performance. +- Rust: sehr robust, aber mehr Entwicklungsaufwand. +- Node.js: gute Web-Entwicklung, aber Runtime-Abhaengigkeit auf Unraid unschoener. +- Python: schnell fuer Prototyp, fuer Distribution auf Unraid weniger ideal. + +Vorteile: + +- Saubere API-Grenze. +- Bessere Kontrolle ueber Jobs, Streaming, Uploads und Suche. +- Moderne SPA moeglich. +- Backend kann strikt erlaubte Pfade erzwingen. +- Tests sind einfacher als bei reinem PHP/Shell. + +Nachteile: + +- Dienst-Lifecycle muss geloest werden. +- Binary-Builds fuer Unraid x86_64 noetig. +- Mehr Paketierungsaufwand. + +Bewertung: +Beste technische Variante fuer U-Navigator. + +### Variante C: Docker-App statt Plugin + +Beschreibung: +U-Navigator laeuft als Docker-Container und wird ueber Community Applications installiert. + +Vorteile: + +- Saubere Isolation. +- Einfache Runtime-Abhaengigkeiten. +- Updates leichter. +- Weniger Risiko fuer das Unraid-WebGUI. + +Nachteile: + +- Fuehlt sich weniger nativ an. +- Integration ins Unraid-WebGUI nur indirekt. +- Rechte und Pfad-Mappings muessen sauber dokumentiert werden. + +Bewertung: +Gut als Alternative oder spaeterer Parallelweg. Wenn das Ziel aber ein echtes Unraid-Plugin im WebGUI ist, ist Variante B passender. + +## Sicherheitsmodell + +Ein Dateimanager auf einem NAS ist sicherheitskritisch. Das Sicherheitsmodell sollte von Anfang an feststehen. + +Mindestregeln: + +- Keine frei uebergebenen Shell-Kommandos. +- Alle Pfade canonicalisieren und gegen erlaubte Root-Pfade pruefen. +- Symlinks bewusst behandeln, standardmaessig nicht aus erlaubten Roots ausbrechen lassen. +- Kein Zugriff auf `/boot`, `/etc`, `/usr`, `/var`, `/root` im MVP. +- Aktionen mit Schreibzugriff getrennt absichern. +- CSRF-Schutz fuer mutierende Requests. +- Upload-Limits. +- Maximalgroessen und Timeouts fuer Vorschauen. +- Lange Operationen als Jobs mit Abbruchmoeglichkeit. +- Audit-Log fuer Schreib-/Loeschoperationen. +- Optionaler Read-only-Modus. + +Wichtig: +Unraid-WebGUI-Authentifizierung darf nicht blind als vollstaendige Autorisierung fuer alle Dateioperationen reichen. U-Navigator braucht zusaetzliche eigene Pfad- und Aktionsregeln. + +## Technische Hauptfragen + +### 1. WebGUI-Integration + +Machbar. Bestehende Plugins installieren Seiten und Assets unter `/usr/local/emhttp/plugins/`. U-Navigator kann dort eine Page registrieren und die SPA laden. + +Offen zu pruefen auf echter Unraid-Instanz: + +- Aktuelle Page-Konventionen unter Unraid 7.x. +- Theme-Kompatibilitaet. +- Auth-/CSRF-Mechanismen. +- Reverse-Proxy-Verhalten des WebGUI. + +### 2. Dateioperationen + +Machbar. Linux/Unraid stellt alle noetigen Dateisystemoperationen bereit. + +Risiken: + +- User Shares unter `/mnt/user` koennen ueber mehrere Disks/Pools verteilt sein. +- Move-Operationen koennen je nach Quelle/Ziel echte Kopien plus Delete sein. +- Sparse Files, Hardlinks, Symlinks und Dateirechte muessen korrekt behandelt werden. +- Sehr grosse Ordner duerfen das WebGUI nicht blockieren. + +### 3. Suche + +Zwei Stufen sind sinnvoll: + +- MVP: nicht-indexierte Namenssuche unter dem aktuellen Root mit Limits. +- Spaeter: Indexdienst mit SQLite, `inotify`/periodischem Scan und Ausschlussregeln. + +Volltextsuche in Dokumenten sollte nicht Teil des MVP sein. + +### 4. Vorschau + +Einfach machbar: + +- Textdateien mit Groessenlimit. +- Bilder ueber Browser oder Backend-Thumbnails. +- PDF optional spaeter. + +Aufwendig: + +- Office-Dokumente. +- Video-Transcoding. +- Exif/Metadatenindex. + +### 5. Paketierung + +Machbar als `.plg` plus Paketarchiv. + +Wichtige Bestandteile: + +- `u-navigator.plg` +- Paket mit `usr/local/emhttp/plugins/u-navigator/...` +- Frontend-Build +- Backend-Binary +- Start-/Stop-Skripte +- Konfiguration unter `/boot/config/plugins/u-navigator/` +- Logs unter `/var/log` oder Plugin-spezifisch mit Rotation + +## Empfohlener MVP-Schnitt + +Version `0.1` sollte klein bleiben: + +- Nur `/mnt/user` als Standardroot. +- Read-only-Modus als globale Option. +- Browse, Suche nach Name, Download. +- Schreibaktionen optional aktivierbar: Upload, Rename, Mkdir, Delete. +- Keine Rechte-/Owner-Aenderungen. +- Keine Archivfunktionen. +- Keine Volltextsuche. +- Keine externen Freigabelinks. + +Damit laesst sich die technische Basis pruefen, ohne das NAS unnoetig zu gefaehrden. + +## Prototyp-Plan + +### Phase 1: Technischer Spike + +Dauer: 1-2 Tage. + +- Minimalen Unraid-Plugin-Skeleton bauen. +- Page im Unraid-WebGUI anzeigen. +- Statische SPA ausliefern. +- API-Endpunkt `GET /api/list?path=...` anbinden. +- Nur read-only Zugriff auf `/mnt/user`. + +Erfolgskriterium: +U-Navigator erscheint im WebGUI und listet Share-Inhalte. + +### Phase 2: Sicherer Dateibrowser + +Dauer: 3-5 Tage. + +- Pfad-Sandbox implementieren. +- Datei-/Ordnerdetails anzeigen. +- Download. +- Upload mit Limit. +- Rename/Mkdir/Delete hinter Feature-Flag. +- Fehlerfaelle sauber anzeigen. + +Erfolgskriterium: +Alltaegliche Dateioperationen funktionieren in erlaubten Shares ohne Shell-Injection- oder Path-Traversal-Risiko. + +### Phase 3: UX Richtung File Station + +Dauer: 1-2 Wochen. + +- Zwei-Pane-Layout oder Baum + Liste. +- Kontextmenues. +- Mehrfachauswahl. +- Drag and Drop. +- Fortschrittsdialoge. +- Bild-/Textvorschau. +- Suchpanel. + +Erfolgskriterium: +Die Anwendung fuehlt sich wie ein nativer NAS-Dateimanager an. + +## Risiken + +### Hoch + +- Sicherheit bei Schreib-/Loeschoperationen. +- Unraid-WebGUI-API und Plugin-Konventionen sind weniger stabil dokumentiert als bei grossen Frameworks. +- Operationen auf `/mnt/user` koennen unerwartete Disk-/Pool-Grenzen beruehren. + +### Mittel + +- Paketierung und Updates. +- Kompatibilitaet mit Unraid 6.12 vs. 7.x. +- Rechte/Owner/ACL-Verhalten. +- Performance bei sehr grossen Ordnern. + +### Niedrig + +- Reine UI-Anzeige. +- Namenssuche. +- Download einzelner Dateien. +- Text-/Bildvorschau mit Limits. + +## Entscheidung + +U-Navigator ist als Unraid-Plugin technisch machbar. + +Die beste Umsetzung ist ein hybrides Plugin: + +- Unraid-Plugin fuer Installation und Einbettung. +- Go-Backend als lokale Binary. +- SPA-Frontend fuer die File-Station-artige Bedienung. +- Striktes Sicherheitsmodell mit erlaubten Roots und Feature-Flags. + +Nicht empfohlen ist ein grosses reines PHP/Shell-Plugin, weil Wartbarkeit, Sicherheit und moderne Bedienung dabei zu schnell gegenlaeufig werden. + +## Quellen und Referenzen + +- Unraid Dokumentation: https://docs.unraid.net/ +- Unraid Dynamix Repository: https://github.com/unraid/dynamix +- Altes Dynamix Repository mit Verweis auf heutige Codebasis: https://github.com/bergware/dynamix +- Community Applications Plugin: https://github.com/Squidly271/community.applications +- Unassigned Devices Plugin: https://github.com/dlandon/unassigned.devices +- Beispiel `.plg` Unassigned Devices: https://raw.githubusercontent.com/dlandon/unassigned.devices/master/unassigned.devices.plg +- Beispiel `.plg` Community Applications: https://raw.githubusercontent.com/Squidly271/community.applications/master/plugins/community.applications.plg diff --git a/package.json b/package.json new file mode 100644 index 0000000..161ad37 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "u-navigator", + "version": "0.1.0", + "private": true, + "description": "Unraid File Navigator prototype with in-app window manager and drag and drop.", + "type": "module", + "scripts": { + "start": "node server/server.js", + "test": "node --test" + }, + "engines": { + "node": ">=20" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..20173c1 --- /dev/null +++ b/public/app.js @@ -0,0 +1,448 @@ +const workspace = document.querySelector('#workspace'); +const statusText = document.querySelector('#statusText'); +const state = { + config: null, + windows: [], + focusedId: null, + selectedEntry: null, + jobs: new Map(), + nextWindowId: 1, + zIndex: 20 +}; + +const icons = { + directory: 'Folder', + file: 'File', + symlink: 'Link', + other: 'Item' +}; + +document.querySelector('#newExplorerButton').addEventListener('click', () => openExplorer()); +document.querySelector('#newPropertiesButton').addEventListener('click', () => openProperties()); +document.querySelector('#newQueueButton').addEventListener('click', () => openQueue()); + +init(); + +async function init() { + try { + state.config = await api('/api/config'); + statusText.textContent = `${state.config.roots.length} Root, ${state.config.readOnly ? 'Read-only' : 'Schreibzugriff aktiv'}`; + openExplorer(state.config.roots[0].path); + openProperties(); + openQueue(); + } catch (error) { + statusText.textContent = error.message; + } +} + +function openExplorer(initialPath = state.config?.roots[0]?.path) { + const win = createWindow('Explorer', 'explorer', { + width: 760, + height: 460, + x: 24 + state.windows.length * 24, + y: 20 + state.windows.length * 20, + data: { + path: initialPath, + entries: [], + selectedPath: null, + loading: false, + error: null + } + }); + loadExplorer(win, initialPath); + return win; +} + +function openProperties() { + return createWindow('Eigenschaften', 'properties', { + width: 420, + height: 320, + x: 160, + y: 90, + data: {} + }); +} + +function openQueue() { + return createWindow('Transfers', 'queue', { + width: 440, + height: 340, + x: 250, + y: 130, + data: {} + }); +} + +function createWindow(title, kind, options) { + const id = `win-${state.nextWindowId++}`; + const win = { + id, + kind, + title, + x: options.x, + y: options.y, + width: options.width, + height: options.height, + maximized: false, + zIndex: ++state.zIndex, + data: options.data || {} + }; + state.windows.push(win); + state.focusedId = id; + render(); + return win; +} + +function render() { + workspace.innerHTML = ''; + for (const win of state.windows) { + workspace.appendChild(renderWindow(win)); + } +} + +function renderWindow(win) { + const el = document.createElement('section'); + el.className = `window ${state.focusedId === win.id ? 'focused' : ''} ${win.maximized ? 'maximized' : ''}`; + el.style.left = `${win.maximized ? 0 : win.x}px`; + el.style.top = `${win.maximized ? 0 : win.y}px`; + el.style.width = `${win.maximized ? workspace.clientWidth : win.width}px`; + el.style.height = `${win.maximized ? workspace.clientHeight : win.height}px`; + el.style.zIndex = win.zIndex; + el.dataset.windowId = win.id; + el.addEventListener('pointerdown', () => focusWindow(win)); + + const titlebar = document.createElement('div'); + titlebar.className = 'titlebar'; + titlebar.innerHTML = ` + ${escapeHtml(windowTitle(win))} +
+ + +
+ `; + titlebar.addEventListener('pointerdown', (event) => startDragWindow(event, win)); + titlebar.querySelector('[data-action="maximize"]').addEventListener('click', (event) => { + event.stopPropagation(); + win.maximized = !win.maximized; + focusWindow(win); + render(); + }); + titlebar.querySelector('[data-action="close"]').addEventListener('click', (event) => { + event.stopPropagation(); + state.windows = state.windows.filter((item) => item.id !== win.id); + state.focusedId = state.windows.at(-1)?.id || null; + render(); + }); + + const body = document.createElement('div'); + body.className = 'window-body'; + if (win.kind === 'explorer') renderExplorer(body, win); + if (win.kind === 'properties') renderProperties(body); + if (win.kind === 'queue') renderQueue(body); + + el.append(titlebar, body); + if (!win.maximized) { + const resize = document.createElement('div'); + resize.className = 'resize-handle'; + resize.addEventListener('pointerdown', (event) => startResizeWindow(event, win)); + el.appendChild(resize); + } + return el; +} + +function renderExplorer(body, win) { + const data = win.data; + body.innerHTML = ` +
+ + + + +
+
+ ${data.error ? `

${escapeHtml(data.error)}

` : ''} + ${data.loading ? '

Lade...

' : renderFileTable(data.entries || [])} +
+ `; + + const input = body.querySelector('input'); + body.querySelector('[data-action="go"]').addEventListener('click', () => loadExplorer(win, input.value)); + body.querySelector('[data-action="refresh"]').addEventListener('click', () => loadExplorer(win, data.path)); + body.querySelector('[data-action="up"]').addEventListener('click', () => loadExplorer(win, parentPath(data.path))); + + const dropZone = body.querySelector('.drop-zone'); + dropZone.addEventListener('dragover', (event) => { + event.preventDefault(); + data.dragOver = true; + dropZone.classList.add('drag-over'); + }); + dropZone.addEventListener('dragleave', () => { + data.dragOver = false; + dropZone.classList.remove('drag-over'); + }); + dropZone.addEventListener('drop', async (event) => { + event.preventDefault(); + data.dragOver = false; + dropZone.classList.remove('drag-over'); + await handleDrop(event, win, data.path); + }); + + for (const row of body.querySelectorAll('.file-row')) { + const entry = data.entries.find((item) => item.path === row.dataset.path); + row.addEventListener('click', () => selectEntry(win, entry)); + row.addEventListener('dblclick', () => { + if (entry.type === 'directory') { + loadExplorer(win, entry.path); + } + }); + row.addEventListener('dragstart', (event) => { + event.dataTransfer.setData('application/x-u-navigator-path', entry.path); + event.dataTransfer.effectAllowed = 'copyMove'; + }); + row.addEventListener('dragover', (event) => { + if (entry.type === 'directory') { + event.preventDefault(); + event.dataTransfer.dropEffect = event.altKey ? 'copy' : 'move'; + } + }); + row.addEventListener('drop', async (event) => { + if (entry.type !== 'directory') return; + event.preventDefault(); + await handleDrop(event, win, entry.path); + }); + } +} + +function renderFileTable(entries) { + if (!entries.length) { + return '

Dieser Ordner ist leer. Dateien können hier abgelegt werden.

'; + } + + const rows = entries.map((entry) => ` + + ${icons[entry.type] || 'Item'}${escapeHtml(entry.name)} + ${entry.type} + ${entry.type === 'file' ? formatBytes(entry.size) : ''} + ${new Date(entry.modifiedAt).toLocaleString()} + + `).join(''); + + return ` + + + ${rows} +
NameTypGrößeGeändert
+ `; +} + +function renderProperties(body) { + const entry = state.selectedEntry; + if (!entry) { + body.innerHTML = '

Keine Datei ausgewählt.

'; + return; + } + + body.classList.add('properties'); + body.innerHTML = ` +
+
Name
${escapeHtml(entry.name)}
+
Typ
${escapeHtml(entry.type)}
+
Pfad
${escapeHtml(entry.path)}
+
Größe
${formatBytes(entry.size)}
+
Geändert
${new Date(entry.modifiedAt).toLocaleString()}
+
+ `; +} + +function renderQueue(body) { + const jobs = [...state.jobs.values()].reverse(); + if (!jobs.length) { + body.innerHTML = '

Keine Transfers.

'; + return; + } + + body.innerHTML = `
${jobs.map((job) => ` +
+ ${escapeHtml(job.type)} #${escapeHtml(job.id)} +
${escapeHtml(job.status)} · ${escapeHtml(job.message || '')}
+
+
+ `).join('')}
`; +} + +async function loadExplorer(win, targetPath) { + win.data.loading = true; + win.data.error = null; + win.data.path = targetPath; + render(); + try { + const result = await api(`/api/list?path=${encodeURIComponent(targetPath)}`); + win.data.path = result.path; + win.data.entries = result.entries; + } catch (error) { + win.data.error = error.message; + } finally { + win.data.loading = false; + render(); + } +} + +async function handleDrop(event, win, targetPath) { + if (state.config.readOnly) { + alert('Read-only mode ist aktiv.'); + return; + } + + const internalPath = event.dataTransfer.getData('application/x-u-navigator-path'); + if (internalPath) { + const name = internalPath.split('/').filter(Boolean).at(-1); + const destination = `${targetPath.replace(/\/$/, '')}/${name}`; + await createJob(event.altKey ? 'copy' : 'move', internalPath, destination); + await loadExplorer(win, win.data.path); + return; + } + + if (event.dataTransfer.files.length) { + const form = new FormData(); + for (const file of event.dataTransfer.files) { + form.append('files', file, file.name); + } + await fetchChecked(`/api/upload?path=${encodeURIComponent(targetPath)}`, { + method: 'POST', + body: form + }); + await loadExplorer(win, targetPath); + } +} + +async function createJob(type, source, destination) { + const job = await api(`/api/jobs/${type}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source, destination }) + }); + state.jobs.set(job.id, job); + pollJob(job.id); + render(); +} + +async function pollJob(id) { + const job = await api(`/api/jobs/${id}`); + state.jobs.set(id, job); + render(); + if (['queued', 'running', 'cancel_requested'].includes(job.status)) { + setTimeout(() => pollJob(id), 700); + } +} + +function selectEntry(win, entry) { + win.data.selectedPath = entry.path; + state.selectedEntry = entry; + focusWindow(win); + render(); +} + +function focusWindow(win) { + state.focusedId = win.id; + win.zIndex = ++state.zIndex; +} + +function startDragWindow(event, win) { + if (event.target.closest('button') || win.maximized) return; + event.preventDefault(); + const startX = event.clientX; + const startY = event.clientY; + const originX = win.x; + const originY = win.y; + const pointerId = event.pointerId; + event.currentTarget.setPointerCapture(pointerId); + + const move = (moveEvent) => { + win.x = clamp(originX + moveEvent.clientX - startX, 0, Math.max(0, workspace.clientWidth - 120)); + win.y = clamp(originY + moveEvent.clientY - startY, 0, Math.max(0, workspace.clientHeight - 80)); + render(); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); +} + +function startResizeWindow(event, win) { + event.preventDefault(); + event.stopPropagation(); + const startX = event.clientX; + const startY = event.clientY; + const originWidth = win.width; + const originHeight = win.height; + + const move = (moveEvent) => { + win.width = Math.max(320, originWidth + moveEvent.clientX - startX); + win.height = Math.max(280, originHeight + moveEvent.clientY - startY); + render(); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); +} + +async function api(url, options) { + const response = await fetchChecked(url, options); + return response.json(); +} + +async function fetchChecked(url, options) { + const response = await fetch(url, options); + if (!response.ok) { + let message = `${response.status} ${response.statusText}`; + try { + message = (await response.json()).error || message; + } catch {} + throw new Error(message); + } + return response; +} + +function windowTitle(win) { + if (win.kind === 'explorer') { + return `Explorer · ${win.data.path || ''}`; + } + return win.title; +} + +function parentPath(value) { + const parts = String(value || '').split('/').filter(Boolean); + if (parts.length <= 1) return value; + return `/${parts.slice(0, -1).join('/')}`; +} + +function formatBytes(bytes) { + const value = Number(bytes || 0); + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`; + return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`; +} + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + })[char]); +} + +function escapeAttr(value) { + return escapeHtml(value); +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..2ee1b18 --- /dev/null +++ b/public/index.html @@ -0,0 +1,26 @@ + + + + + + U-Navigator + + + +
+
+
+

U-Navigator

+

Initialisiere...

+
+ +
+
+
+ + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..e60dfd4 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,331 @@ +:root { + color-scheme: light dark; + --bg: #f4f6f8; + --surface: #ffffff; + --surface-2: #eef2f5; + --text: #18222d; + --muted: #617182; + --line: #cfd8e1; + --accent: #e86f2d; + --accent-2: #287a65; + --danger: #b52c34; + --shadow: 0 18px 55px rgba(24, 34, 45, 0.22); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #101418; + --surface: #1b2229; + --surface-2: #242e37; + --text: #eef3f7; + --muted: #a3b0bd; + --line: #36434f; + --shadow: 0 18px 55px rgba(0, 0, 0, 0.44); + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); + color: var(--text); + cursor: pointer; + font: inherit; + min-height: 32px; + padding: 0 10px; +} + +button:hover { + border-color: var(--accent); +} + +button.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +button.icon { + align-items: center; + display: inline-flex; + height: 30px; + justify-content: center; + min-height: 30px; + padding: 0; + width: 32px; +} + +.app-shell { + display: grid; + grid-template-rows: auto 1fr; + min-height: 100vh; +} + +.topbar { + align-items: center; + background: var(--surface); + border-bottom: 1px solid var(--line); + display: flex; + gap: 16px; + justify-content: space-between; + padding: 12px 16px; +} + +.topbar h1 { + font-size: 18px; + line-height: 1; + margin: 0 0 4px; +} + +.topbar p { + color: var(--muted); + margin: 0; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.workspace { + min-height: 0; + overflow: hidden; + position: relative; +} + +.window { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + display: grid; + grid-template-rows: auto 1fr; + min-height: 280px; + min-width: 320px; + overflow: hidden; + position: absolute; +} + +.window.focused { + border-color: color-mix(in srgb, var(--accent) 62%, var(--line)); +} + +.window.maximized { + border-radius: 0; +} + +.titlebar { + align-items: center; + background: var(--surface-2); + border-bottom: 1px solid var(--line); + cursor: move; + display: grid; + gap: 8px; + grid-template-columns: 1fr auto; + min-height: 40px; + padding: 5px 8px 5px 12px; + user-select: none; +} + +.titlebar strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.window-actions { + display: flex; + gap: 6px; +} + +.window-body { + min-height: 0; + overflow: auto; + padding: 10px; +} + +.resize-handle { + bottom: 0; + cursor: nwse-resize; + height: 18px; + position: absolute; + right: 0; + width: 18px; +} + +.resize-handle::after { + border-bottom: 2px solid var(--muted); + border-right: 2px solid var(--muted); + bottom: 5px; + content: ""; + height: 8px; + position: absolute; + right: 5px; + width: 8px; +} + +.pathbar { + align-items: center; + display: grid; + gap: 8px; + grid-template-columns: auto 1fr auto auto; + margin-bottom: 10px; +} + +.pathbar input { + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + font: inherit; + min-height: 32px; + padding: 0 9px; + width: 100%; +} + +.drop-zone { + border: 1px dashed transparent; + border-radius: 8px; + min-height: 180px; +} + +.drop-zone.drag-over { + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-color: var(--accent); +} + +.file-table { + border-collapse: collapse; + width: 100%; +} + +.file-table th, +.file-table td { + border-bottom: 1px solid var(--line); + padding: 8px 7px; + text-align: left; + vertical-align: middle; +} + +.file-table th { + color: var(--muted); + font-size: 12px; + font-weight: 600; +} + +.file-row { + cursor: default; +} + +.file-row:hover, +.file-row.selected { + background: var(--surface-2); +} + +.name-cell { + align-items: center; + display: flex; + gap: 8px; + min-width: 150px; +} + +.badge { + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + display: inline-flex; + font-size: 12px; + justify-content: center; + min-width: 58px; + padding: 2px 8px; +} + +.muted { + color: var(--muted); +} + +.queue-list { + display: grid; + gap: 10px; +} + +.job { + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px; +} + +.progress { + background: var(--surface-2); + border-radius: 999px; + height: 8px; + margin-top: 8px; + overflow: hidden; +} + +.progress div { + background: var(--accent-2); + height: 100%; + width: 0; +} + +.properties dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 8px 14px; + margin: 0; +} + +.properties dt { + color: var(--muted); +} + +.properties dd { + margin: 0; + overflow-wrap: anywhere; +} + +@media (max-width: 720px) { + .topbar { + align-items: flex-start; + flex-direction: column; + } + + .workspace { + overflow: auto; + } + + .window, + .window.maximized { + border-left: 0; + border-radius: 0; + border-right: 0; + height: calc(100vh - 120px) !important; + left: 0 !important; + min-width: 0; + position: relative; + top: 0 !important; + width: 100% !important; + } + + .window:not(.focused) { + display: none; + } + + .resize-handle { + display: none; + } +} diff --git a/server/jobs.js b/server/jobs.js new file mode 100644 index 0000000..d2d1978 --- /dev/null +++ b/server/jobs.js @@ -0,0 +1,125 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export class JobStore { + constructor() { + this.jobs = new Map(); + this.nextId = 1; + } + + create(type, task) { + const id = String(this.nextId++); + const job = { + id, + type, + status: 'queued', + progress: 0, + message: '', + error: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + cancelRequested: false + }; + + this.jobs.set(id, job); + queueMicrotask(async () => { + await this.run(job, task); + }); + return this.publicJob(job); + } + + get(id) { + const job = this.jobs.get(String(id)); + return job ? this.publicJob(job) : null; + } + + cancel(id) { + const job = this.jobs.get(String(id)); + if (!job) { + return null; + } + job.cancelRequested = true; + this.touch(job, 'cancel_requested', job.progress, 'Cancel requested'); + return this.publicJob(job); + } + + async run(job, task) { + try { + this.touch(job, 'running', 1, 'Running'); + await task({ + isCanceled: () => job.cancelRequested, + progress: (progress, message = job.message) => this.touch(job, 'running', progress, message) + }); + if (job.cancelRequested) { + this.touch(job, 'canceled', job.progress, 'Canceled'); + } else { + this.touch(job, 'completed', 100, 'Completed'); + } + } catch (error) { + this.touch(job, job.cancelRequested ? 'canceled' : 'failed', job.progress, error.message); + job.error = error.message; + } + } + + touch(job, status, progress, message) { + job.status = status; + job.progress = Math.max(0, Math.min(100, Math.round(progress))); + job.message = message; + job.updatedAt = new Date().toISOString(); + } + + publicJob(job) { + const { cancelRequested, ...publicFields } = job; + return publicFields; + } +} + +export async function movePath(source, destination, ctx) { + if (ctx.isCanceled()) { + throw new Error('Canceled'); + } + ctx.progress(10, 'Moving'); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.rename(source, destination); + ctx.progress(95, 'Move finished'); +} + +export async function copyPath(source, destination, ctx) { + if (ctx.isCanceled()) { + throw new Error('Canceled'); + } + ctx.progress(5, 'Preparing copy'); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await copyRecursive(source, destination, ctx); + ctx.progress(95, 'Copy finished'); +} + +async function copyRecursive(source, destination, ctx) { + if (ctx.isCanceled()) { + throw new Error('Canceled'); + } + + const stat = await fs.lstat(source); + if (stat.isSymbolicLink()) { + throw new Error('Copying symlinks is not allowed'); + } + + if (stat.isDirectory()) { + await fs.mkdir(destination, { recursive: true, mode: stat.mode }); + const entries = await fs.readdir(source); + let done = 0; + for (const entry of entries) { + await copyRecursive(path.join(source, entry), path.join(destination, entry), ctx); + done += 1; + ctx.progress(10 + (done / Math.max(entries.length, 1)) * 80, 'Copying directory'); + } + return; + } + + if (!stat.isFile()) { + throw new Error('Only files and directories can be copied'); + } + + await fs.copyFile(source, destination); + ctx.progress(85, 'Copying file'); +} diff --git a/server/multipart.js b/server/multipart.js new file mode 100644 index 0000000..6f0e2f4 --- /dev/null +++ b/server/multipart.js @@ -0,0 +1,59 @@ +export async function readRequestBody(req, limitBytes) { + const chunks = []; + let size = 0; + + for await (const chunk of req) { + size += chunk.length; + if (size > limitBytes) { + const error = new Error('Request body is too large'); + error.status = 413; + throw error; + } + chunks.push(chunk); + } + + return Buffer.concat(chunks); +} + +export function parseMultipart(buffer, contentType) { + const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || ''); + if (!match) { + const error = new Error('Missing multipart boundary'); + error.status = 400; + throw error; + } + + const boundary = `--${match[1] || match[2]}`; + const binary = buffer.toString('binary'); + const parts = []; + + for (const raw of binary.split(boundary)) { + if (!raw || raw === '--\r\n' || raw === '--') { + continue; + } + + const trimmed = raw.replace(/^\r\n/, '').replace(/\r\n--$/, ''); + const separator = trimmed.indexOf('\r\n\r\n'); + if (separator === -1) { + continue; + } + + const headerText = trimmed.slice(0, separator); + let bodyText = trimmed.slice(separator + 4); + if (bodyText.endsWith('\r\n')) { + bodyText = bodyText.slice(0, -2); + } + + const disposition = /content-disposition:\s*form-data;\s*([^\r\n]+)/i.exec(headerText)?.[1] || ''; + const name = /name="([^"]+)"/i.exec(disposition)?.[1] || ''; + const filename = /filename="([^"]*)"/i.exec(disposition)?.[1] || ''; + + parts.push({ + name, + filename, + data: Buffer.from(bodyText, 'binary') + }); + } + + return parts; +} diff --git a/server/sandbox.js b/server/sandbox.js new file mode 100644 index 0000000..001ad73 --- /dev/null +++ b/server/sandbox.js @@ -0,0 +1,114 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export class Sandbox { + constructor(roots, options = {}) { + if (!roots.length) { + throw new Error('At least one allowed root is required'); + } + + this.readOnly = Boolean(options.readOnly); + this.roots = roots.map((root, index) => ({ + id: `root${index + 1}`, + label: root, + path: path.resolve(root) + })); + } + + async init() { + const usable = []; + + for (const root of this.roots) { + const real = await fs.realpath(root.path); + const stat = await fs.stat(real); + if (!stat.isDirectory()) { + throw new Error(`Allowed root is not a directory: ${root.path}`); + } + usable.push({ ...root, real }); + } + + this.roots = usable; + return this; + } + + publicConfig() { + return { + readOnly: this.readOnly, + roots: this.roots.map((root) => ({ + id: root.id, + label: root.label, + path: root.real + })) + }; + } + + assertWritable() { + if (this.readOnly) { + const error = new Error('Read-only mode is enabled'); + error.status = 403; + throw error; + } + } + + rootFor(inputPath = '/') { + const normalized = String(inputPath || '/'); + + if (normalized === '/' || normalized === '') { + return this.roots[0]; + } + + for (const root of this.roots) { + if (normalized === root.real || normalized.startsWith(`${root.real}${path.sep}`)) { + return root; + } + } + + const error = new Error('Path is outside allowed roots'); + error.status = 403; + throw error; + } + + rootForReal(realPath) { + for (const root of this.roots) { + if (this.isInside(realPath, root.real)) { + return root; + } + } + + const error = new Error('Path is outside allowed roots'); + error.status = 403; + throw error; + } + + async resolveExisting(inputPath = '/') { + const candidate = path.resolve(String(inputPath || this.roots[0].real)); + const real = await fs.realpath(candidate); + const root = this.rootForReal(real); + + return { root, path: candidate, real }; + } + + async resolveDestination(inputPath) { + const candidate = path.resolve(String(inputPath || '')); + const parent = path.dirname(candidate); + const parentReal = await fs.realpath(parent); + const root = this.rootForReal(parentReal); + + return { root, path: candidate, parentReal }; + } + + isInside(candidate, root) { + const relative = path.relative(root, candidate); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + } +} + +export function rootsFromEnv(env = process.env) { + if (env.U_NAV_ROOTS) { + return env.U_NAV_ROOTS.split(',').map((item) => item.trim()).filter(Boolean); + } + if (env.U_NAV_ROOT) { + return [env.U_NAV_ROOT]; + } + return ['/mnt/user']; +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..480db9b --- /dev/null +++ b/server/server.js @@ -0,0 +1,237 @@ +import fs from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { JobStore, copyPath, movePath } from './jobs.js'; +import { parseMultipart, readRequestBody } from './multipart.js'; +import { Sandbox, rootsFromEnv } from './sandbox.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); +const publicDir = path.join(projectRoot, 'public'); + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png' +}; + +export async function createApp(options = {}) { + const sandbox = await new Sandbox(options.roots || rootsFromEnv(options.env), { + readOnly: options.readOnly ?? options.env?.U_NAV_READ_ONLY === '1' + }).init(); + const jobs = new JobStore(); + const uploadLimit = Number(options.uploadLimit || options.env?.U_NAV_UPLOAD_LIMIT || 1024 * 1024 * 100); + + return async function app(req, res) { + try { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + + if (url.pathname.startsWith('/api/')) { + await routeApi(req, res, url, sandbox, jobs, uploadLimit); + return; + } + + await serveStatic(req, res, url); + } catch (error) { + sendJson(res, error.status || 500, { + error: error.message || 'Internal server error' + }); + } + }; +} + +async function routeApi(req, res, url, sandbox, jobs, uploadLimit) { + if (req.method === 'GET' && url.pathname === '/api/config') { + sendJson(res, 200, sandbox.publicConfig()); + return; + } + + if (req.method === 'GET' && url.pathname === '/api/list') { + const resolved = await sandbox.resolveExisting(url.searchParams.get('path') || sandbox.roots[0].real); + const entries = await listDirectory(resolved.real); + sendJson(res, 200, { path: resolved.real, entries }); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/upload') { + sandbox.assertWritable(); + const target = url.searchParams.get('path'); + const resolved = await sandbox.resolveExisting(target); + const stat = await fs.stat(resolved.real); + if (!stat.isDirectory()) { + throw statusError(400, 'Upload target must be a directory'); + } + const body = await readRequestBody(req, uploadLimit); + const parts = parseMultipart(body, req.headers['content-type']); + const saved = []; + for (const part of parts.filter((item) => item.name === 'files' && item.filename)) { + const safeName = path.basename(part.filename); + const destination = path.join(resolved.real, safeName); + await sandbox.resolveDestination(destination); + await fs.writeFile(destination, part.data, { flag: 'wx' }); + saved.push({ name: safeName, size: part.data.length }); + } + sendJson(res, 201, { uploaded: saved }); + return; + } + + if (req.method === 'POST' && (url.pathname === '/api/jobs/move' || url.pathname === '/api/jobs/copy')) { + sandbox.assertWritable(); + const body = await jsonBody(req); + const source = await sandbox.resolveExisting(body.source); + const destination = await sandbox.resolveDestination(body.destination); + const type = url.pathname.endsWith('/copy') ? 'copy' : 'move'; + await ensureDestinationAvailable(destination.path); + ensureNotNested(source.real, destination.path); + + const job = jobs.create(type, async (ctx) => { + if (type === 'copy') { + await copyPath(source.real, destination.path, ctx); + } else { + await movePath(source.real, destination.path, ctx); + } + }); + sendJson(res, 202, job); + return; + } + + const jobMatch = /^\/api\/jobs\/([^/]+)$/.exec(url.pathname); + if (req.method === 'GET' && jobMatch) { + const job = jobs.get(jobMatch[1]); + if (!job) { + throw statusError(404, 'Job not found'); + } + sendJson(res, 200, job); + return; + } + + if (req.method === 'POST' && /^\/api\/jobs\/([^/]+)\/cancel$/.test(url.pathname)) { + const id = /^\/api\/jobs\/([^/]+)\/cancel$/.exec(url.pathname)[1]; + const job = jobs.cancel(id); + if (!job) { + throw statusError(404, 'Job not found'); + } + sendJson(res, 200, job); + return; + } + + throw statusError(404, 'API route not found'); +} + +async function listDirectory(directory) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const result = []; + + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + const stat = await fs.lstat(fullPath); + result.push({ + name: entry.name, + path: fullPath, + type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : entry.isSymbolicLink() ? 'symlink' : 'other', + size: stat.size, + modifiedAt: stat.mtime.toISOString() + }); + } + + result.sort((a, b) => { + if (a.type === 'directory' && b.type !== 'directory') return -1; + if (a.type !== 'directory' && b.type === 'directory') return 1; + return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); + }); + return result; +} + +async function serveStatic(req, res, url) { + if (req.method !== 'GET' && req.method !== 'HEAD') { + throw statusError(405, 'Method not allowed'); + } + + const pathname = url.pathname === '/' ? '/index.html' : url.pathname; + const requested = path.resolve(publicDir, `.${decodeURIComponent(pathname)}`); + const relative = path.relative(publicDir, requested); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw statusError(403, 'Static path outside public directory'); + } + + try { + const stat = await fs.stat(requested); + if (!stat.isFile()) { + throw statusError(404, 'Not found'); + } + res.writeHead(200, { + 'Content-Type': MIME[path.extname(requested)] || 'application/octet-stream', + 'Content-Length': stat.size + }); + if (req.method === 'HEAD') { + res.end(); + } else { + createReadStream(requested).pipe(res); + } + } catch (error) { + if (error.code === 'ENOENT') { + throw statusError(404, 'Not found'); + } + throw error; + } +} + +async function ensureDestinationAvailable(destination) { + try { + await fs.lstat(destination); + throw statusError(409, 'Destination already exists'); + } catch (error) { + if (error.code === 'ENOENT') { + return; + } + throw error; + } +} + +function ensureNotNested(source, destination) { + const relative = path.relative(source, destination); + if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) { + throw statusError(400, 'Destination cannot be inside source'); + } +} + +async function jsonBody(req) { + const body = await readRequestBody(req, 1024 * 1024); + try { + return JSON.parse(body.toString('utf8') || '{}'); + } catch { + throw statusError(400, 'Invalid JSON body'); + } +} + +function sendJson(res, status, payload) { + const json = JSON.stringify(payload); + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(json) + }); + res.end(json); +} + +function statusError(status, message) { + const error = new Error(message); + error.status = status; + return error; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const port = Number(process.env.PORT || 8088); + createApp({ env: process.env }).then((app) => { + http.createServer(app).listen(port, '127.0.0.1', () => { + console.log(`U-Navigator listening on http://127.0.0.1:${port}`); + }); + }).catch((error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/server/server.test.js b/server/server.test.js new file mode 100644 index 0000000..d818842 --- /dev/null +++ b/server/server.test.js @@ -0,0 +1,109 @@ +import fs from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp } from './server.js'; + +let root; +let outside; +let baseUrl; +let server; + +describe('U-Navigator API', () => { + before(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'u-nav-root-')); + outside = await fs.mkdtemp(path.join(os.tmpdir(), 'u-nav-outside-')); + await fs.mkdir(path.join(root, 'share')); + await fs.writeFile(path.join(root, 'share', 'alpha.txt'), 'alpha'); + await fs.writeFile(path.join(outside, 'secret.txt'), 'secret'); + await fs.symlink(outside, path.join(root, 'share', 'escape')); + + const app = await createApp({ roots: [root] }); + server = http.createServer(app); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + baseUrl = `http://127.0.0.1:${server.address().port}`; + }); + + after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await fs.rm(root, { recursive: true, force: true }); + await fs.rm(outside, { recursive: true, force: true }); + }); + + it('lists files inside the configured root', async () => { + const result = await json(`/api/list?path=${encodeURIComponent(path.join(root, 'share'))}`); + assert.equal(result.entries.some((entry) => entry.name === 'alpha.txt'), true); + }); + + it('blocks path traversal outside the root', async () => { + const response = await fetch(`${baseUrl}/api/list?path=${encodeURIComponent(outside)}`); + assert.equal(response.status, 403); + }); + + it('blocks symlink escapes outside the root', async () => { + const response = await fetch(`${baseUrl}/api/list?path=${encodeURIComponent(path.join(root, 'share', 'escape'))}`); + assert.equal(response.status, 403); + }); + + it('uploads multipart files into an allowed directory', async () => { + const form = new FormData(); + form.append('files', new Blob(['hello']), 'upload.txt'); + const response = await fetch(`${baseUrl}/api/upload?path=${encodeURIComponent(path.join(root, 'share'))}`, { + method: 'POST', + body: form + }); + assert.equal(response.status, 201); + assert.equal(await fs.readFile(path.join(root, 'share', 'upload.txt'), 'utf8'), 'hello'); + }); + + it('creates move jobs for internal drag and drop operations', async () => { + await fs.writeFile(path.join(root, 'share', 'move-me.txt'), 'move'); + await fs.mkdir(path.join(root, 'target')); + const response = await fetch(`${baseUrl}/api/jobs/move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: path.join(root, 'share', 'move-me.txt'), + destination: path.join(root, 'target', 'move-me.txt') + }) + }); + assert.equal(response.status, 202); + const job = await response.json(); + await waitForJob(job.id); + assert.equal(await fs.readFile(path.join(root, 'target', 'move-me.txt'), 'utf8'), 'move'); + }); + + it('rejects destinations that already exist', async () => { + const response = await fetch(`${baseUrl}/api/jobs/copy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: path.join(root, 'share', 'alpha.txt'), + destination: path.join(root, 'share', 'upload.txt') + }) + }); + assert.equal(response.status, 409); + }); +}); + +async function json(pathname) { + const response = await fetch(`${baseUrl}${pathname}`); + assert.equal(response.ok, true); + return response.json(); +} + +async function waitForJob(id) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const job = await json(`/api/jobs/${id}`); + if (job.status === 'completed') return job; + if (job.status === 'failed') throw new Error(job.error); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error('Timed out waiting for job'); +}