Initial U-Navigator prototype
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
})[char]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value) {
|
||||||
|
return escapeHtml(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user