Initial U-Navigator prototype

This commit is contained in:
Mikei386
2026-06-22 22:11:54 +02:00
commit 37ddea08f4
11 changed files with 1841 additions and 0 deletions
+40
View File
@@ -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.
+338
View File
@@ -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/<plugin>` installiert.
- Persistente Plugin-Konfiguration liegt typischerweise unter `/boot/config/plugins/<plugin>`.
- 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/<plugin>`. 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
+14
View File
@@ -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"
}
}
+448
View File
@@ -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 = `
<strong>${escapeHtml(windowTitle(win))}</strong>
<div class="window-actions">
<button class="icon" data-action="maximize" title="Maximieren">${win.maximized ? '↙' : '□'}</button>
<button class="icon" data-action="close" title="Schließen">×</button>
</div>
`;
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 = `
<div class="pathbar">
<button data-action="up">Auf</button>
<input value="${escapeAttr(data.path || '')}" aria-label="Pfad">
<button data-action="go">Öffnen</button>
<button data-action="refresh">Neu laden</button>
</div>
<div class="drop-zone ${data.dragOver ? 'drag-over' : ''}">
${data.error ? `<p class="muted">${escapeHtml(data.error)}</p>` : ''}
${data.loading ? '<p class="muted">Lade...</p>' : renderFileTable(data.entries || [])}
</div>
`;
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 '<p class="muted">Dieser Ordner ist leer. Dateien können hier abgelegt werden.</p>';
}
const rows = entries.map((entry) => `
<tr class="file-row" draggable="true" data-path="${escapeAttr(entry.path)}">
<td><span class="name-cell"><span class="badge">${icons[entry.type] || 'Item'}</span>${escapeHtml(entry.name)}</span></td>
<td>${entry.type}</td>
<td>${entry.type === 'file' ? formatBytes(entry.size) : ''}</td>
<td>${new Date(entry.modifiedAt).toLocaleString()}</td>
</tr>
`).join('');
return `
<table class="file-table">
<thead><tr><th>Name</th><th>Typ</th><th>Größe</th><th>Geändert</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`;
}
function renderProperties(body) {
const entry = state.selectedEntry;
if (!entry) {
body.innerHTML = '<p class="muted">Keine Datei ausgewählt.</p>';
return;
}
body.classList.add('properties');
body.innerHTML = `
<dl>
<dt>Name</dt><dd>${escapeHtml(entry.name)}</dd>
<dt>Typ</dt><dd>${escapeHtml(entry.type)}</dd>
<dt>Pfad</dt><dd>${escapeHtml(entry.path)}</dd>
<dt>Größe</dt><dd>${formatBytes(entry.size)}</dd>
<dt>Geändert</dt><dd>${new Date(entry.modifiedAt).toLocaleString()}</dd>
</dl>
`;
}
function renderQueue(body) {
const jobs = [...state.jobs.values()].reverse();
if (!jobs.length) {
body.innerHTML = '<p class="muted">Keine Transfers.</p>';
return;
}
body.innerHTML = `<div class="queue-list">${jobs.map((job) => `
<article class="job">
<strong>${escapeHtml(job.type)} #${escapeHtml(job.id)}</strong>
<div class="muted">${escapeHtml(job.status)} · ${escapeHtml(job.message || '')}</div>
<div class="progress"><div style="width:${Number(job.progress) || 0}%"></div></div>
</article>
`).join('')}</div>`;
}
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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[char]);
}
function escapeAttr(value) {
return escapeHtml(value);
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>U-Navigator</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div>
<h1>U-Navigator</h1>
<p id="statusText">Initialisiere...</p>
</div>
<nav class="toolbar" aria-label="Workspace actions">
<button id="newExplorerButton" title="Explorer öffnen">Explorer</button>
<button id="newPropertiesButton" title="Eigenschaften öffnen">Eigenschaften</button>
<button id="newQueueButton" title="Transfer-Queue öffnen">Transfers</button>
</nav>
</header>
<main id="workspace" class="workspace" aria-label="U-Navigator Workspace"></main>
</div>
<script src="/app.js" type="module"></script>
</body>
</html>
+331
View File
@@ -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;
}
}
+125
View File
@@ -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');
}
+59
View File
@@ -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;
}
+114
View File
@@ -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'];
}
+237
View File
@@ -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);
});
}
+109
View File
@@ -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');
}