diff --git a/docs/browser-token-auth.md b/docs/browser-token-auth.md index cd3e60d..b3bf2f0 100644 --- a/docs/browser-token-auth.md +++ b/docs/browser-token-auth.md @@ -59,7 +59,7 @@ INXT_MNEMONIC=word1 word2 word3 ... npm start ``` -Server läuft auf `http://127.0.0.1:3005`. Phase 1 (PROPFIND) ist aktiv – Verzeichnisinhalt kann aufgelistet werden. Windows Explorer: Netzlaufwerk verbinden → `http://127.0.0.1:3005`. +Server läuft auf `http://127.0.0.1:3005`. Phase 1+2 aktiv: PROPFIND, MKCOL (Ordner anlegen), DELETE, MOVE. Windows Explorer: Netzlaufwerk verbinden → `http://127.0.0.1:3005`. ## Hinweise diff --git a/docs/webdav-architektur.md b/docs/webdav-architektur.md index 825644d..26a6fcf 100644 --- a/docs/webdav-architektur.md +++ b/docs/webdav-architektur.md @@ -51,7 +51,7 @@ Die drive-web nutzt `Network.client` (Bridge) und `NetworkFacade` für Up-/Downl ## Implementierungsreihenfolge 1. **Phase 1:** PROPFIND (Verzeichnis auflisten) – ✅ implementiert -2. **Phase 2:** MKCOL, DELETE, MOVE +2. **Phase 2:** MKCOL, DELETE, MOVE – ✅ implementiert 3. **Phase 3:** GET (Download) – Bridge + Entschlüsselung 4. **Phase 4:** PUT (Upload) – Verschlüsselung + Bridge diff --git a/src/path-resolver.js b/src/path-resolver.js index 6b33a2d..17009af 100644 --- a/src/path-resolver.js +++ b/src/path-resolver.js @@ -15,7 +15,7 @@ export function pathToSegments(path) { * Baut Pfad aus Segmenten. */ export function segmentsToPath(segments) { - return '/' + (Array.isArray(segments) ? segments.join('/') : ''); + return '/' + (Array.isArray(segments) ? segments.filter(Boolean).join('/') : ''); } /** @@ -70,3 +70,39 @@ export async function listFolder(storage, rootFolderUuid, path) { return { folders, files }; } + +/** + * Resolved einen WebDAV-Pfad zu einer Datei oder einem Ordner. + * @returns {Promise<{ uuid: string, type: 'folder'|'file', name: string, parentUuid?: string } | null>} + */ +export async function resolveResource(storage, rootFolderUuid, path) { + const segments = pathToSegments(path); + if (segments.length === 0) { + return { uuid: rootFolderUuid, type: 'folder', name: 'Drive' }; + } + + const parentPath = segments.slice(0, -1); + const childName = segments[segments.length - 1]; + + const parent = await resolveFolder(storage, rootFolderUuid, segmentsToPath(parentPath)); + if (!parent) return null; + + const [contentPromise] = storage.getFolderContentByUuid({ folderUuid: parent.uuid }); + const content = await contentPromise; + + const folder = content?.children?.find( + (c) => (c.plain_name || c.name || '').toLowerCase() === childName.toLowerCase() + ); + if (folder) { + return { uuid: folder.uuid, type: 'folder', name: folder.plain_name || folder.name, parentUuid: parent.uuid }; + } + + const file = content?.files?.find( + (f) => (f.plain_name || f.name || f.plainName || '').toLowerCase() === childName.toLowerCase() + ); + if (file) { + return { uuid: file.uuid, type: 'file', name: file.plain_name || file.name || file.plainName, parentUuid: parent.uuid }; + } + + return null; +} diff --git a/src/server.js b/src/server.js index ffae028..20611fc 100644 --- a/src/server.js +++ b/src/server.js @@ -1,5 +1,5 @@ /** - * WebDAV-Server für Internxt Drive (Phase 1: PROPFIND) + * WebDAV-Server für Internxt Drive (Phase 1+2: PROPFIND, MKCOL, DELETE, MOVE) * * Nutzt Browser-Token (INXT_TOKEN, INXT_MNEMONIC) aus .env. * Siehe docs/browser-token-auth.md @@ -8,7 +8,7 @@ import 'dotenv/config'; import express from 'express'; import { createClients, refreshUser } from './internxt-client.js'; -import { pathToSegments, listFolder } from './path-resolver.js'; +import { pathToSegments, segmentsToPath, listFolder, resolveFolder, resolveResource } from './path-resolver.js'; const PORT = parseInt(process.env.PORT || '3005', 10); const token = process.env.INXT_TOKEN; @@ -158,6 +158,197 @@ async function handlePropfind(req, res) { } } +/** + * Holt Root-UUID und Storage (mit Token-Check). + */ +async function getContext() { + const { storage } = createClients(token); + const refresh = await refreshUser(token); + const user = refresh.user; + const rootUuid = user?.rootFolderUuid || user?.rootFolderId || user?.root_folder_id; + if (!rootUuid) throw new Error('Root-Ordner nicht gefunden'); + return { storage, rootUuid }; +} + +/** + * MKCOL Handler – Ordner anlegen + */ +async function handleMkcol(req, res) { + let path = req.url || '/'; + try { + path = decodeURIComponent(path); + } catch (_) {} + if (!path.startsWith('/')) path = '/' + path; + if (path === '/') { + res.status(403).send('Root kann nicht erstellt werden'); + return; + } + if (path.endsWith('/')) path = path.slice(0, -1); + + const segments = pathToSegments(path); + if (segments.length === 0) { + res.status(403).send('Root bereits vorhanden'); + return; + } + + const parentPath = segmentsToPath(segments.slice(0, -1)); + const newName = segments[segments.length - 1]; + + try { + const { storage, rootUuid } = await getContext(); + const parent = await resolveFolder(storage, rootUuid, parentPath); + if (!parent) { + res.status(409).send('Übergeordneter Ordner existiert nicht'); + return; + } + + const existing = await resolveResource(storage, rootUuid, path); + if (existing) { + res.status(405).send('Ressource existiert bereits'); + return; + } + + const [createPromise] = storage.createFolderByUuid({ + parentFolderUuid: parent.uuid, + plainName: newName, + }); + await createPromise; + res.status(201).send(); + } catch (err) { + console.error('MKCOL Fehler:', err.message); + if (err.message?.includes('Token') || err.response?.status === 401) { + res.status(401).send('Nicht autorisiert'); + return; + } + res.status(500).send(err.message || 'Interner Fehler'); + } +} + +/** + * DELETE Handler – Datei oder Ordner löschen + */ +async function handleDelete(req, res) { + let path = req.url || '/'; + try { + path = decodeURIComponent(path); + } catch (_) {} + if (!path.startsWith('/')) path = '/' + path; + if (path.endsWith('/')) path = path.slice(0, -1); + if (path === '/') { + res.status(403).send('Root kann nicht gelöscht werden'); + return; + } + + try { + const { storage, rootUuid } = await getContext(); + const resource = await resolveResource(storage, rootUuid, path); + if (!resource) { + res.status(404).send('Nicht gefunden'); + return; + } + + if (resource.type === 'folder') { + await storage.deleteFolderByUuid(resource.uuid); + } else { + await storage.deleteFileByUuid(resource.uuid); + } + res.status(204).send(); + } catch (err) { + console.error('DELETE Fehler:', err.message); + if (err.message?.includes('Token') || err.response?.status === 401) { + res.status(401).send('Nicht autorisiert'); + return; + } + res.status(500).send(err.message || 'Interner Fehler'); + } +} + +/** + * MOVE Handler – Datei oder Ordner verschieben/umbenennen + */ +async function handleMove(req, res) { + let path = req.url || '/'; + const destinationHeader = req.headers['destination']; + if (!destinationHeader) { + res.status(400).send('Destination-Header fehlt'); + return; + } + + try { + path = decodeURIComponent(path); + } catch (_) {} + if (!path.startsWith('/')) path = '/' + path; + if (path.endsWith('/')) path = path.slice(0, -1); + if (path === '/') { + res.status(403).send('Root kann nicht verschoben werden'); + return; + } + + let destPath; + try { + const destUrl = new URL(destinationHeader); + destPath = decodeURIComponent(destUrl.pathname || '/'); + } catch (_) { + res.status(400).send('Ungültige Destination-URL'); + return; + } + if (!destPath.startsWith('/')) destPath = '/' + destPath; + if (destPath.endsWith('/')) destPath = destPath.slice(0, -1); + + const overwrite = (req.headers['overwrite'] || 'T').toUpperCase() === 'T'; + + try { + const { storage, rootUuid } = await getContext(); + const source = await resolveResource(storage, rootUuid, path); + if (!source) { + res.status(404).send('Quelle nicht gefunden'); + return; + } + + const destSegments = pathToSegments(destPath); + const destParentPath = segmentsToPath(destSegments.slice(0, -1)); + const destName = destSegments[destSegments.length - 1]; + + const destParent = await resolveFolder(storage, rootUuid, destParentPath); + if (!destParent) { + res.status(409).send('Zielordner existiert nicht'); + return; + } + + const existingDest = await resolveResource(storage, rootUuid, destPath); + if (existingDest) { + if (!overwrite) { + res.status(412).send('Ziel existiert, Overwrite nicht erlaubt'); + return; + } + if (existingDest.type === 'folder') { + await storage.deleteFolderByUuid(existingDest.uuid); + } else { + await storage.deleteFileByUuid(existingDest.uuid); + } + } + + const payload = { destinationFolder: destParent.uuid }; + if (destName && destName !== source.name) { + payload.name = destName; + } + + if (source.type === 'folder') { + await storage.moveFolderByUuid(source.uuid, payload); + } else { + await storage.moveFileByUuid(source.uuid, payload); + } + res.status(201).send(); + } catch (err) { + console.error('MOVE Fehler:', err.message); + if (err.message?.includes('Token') || err.response?.status === 401) { + res.status(401).send('Nicht autorisiert'); + return; + } + res.status(500).send(err.message || 'Interner Fehler'); + } +} + // WebDAV Endpoints app.options('*', (req, res) => { res.set('DAV', '1, 2'); @@ -180,15 +371,24 @@ app.use((req, res, next) => { return; } if (req.method === 'DELETE') { - res.status(501).send('DELETE noch nicht implementiert – Phase 2'); + handleDelete(req, res).catch((err) => { + console.error('DELETE unhandled:', err); + if (!res.headersSent) res.status(500).send(err.message); + }); return; } if (req.method === 'MKCOL') { - res.status(501).send('MKCOL noch nicht implementiert – Phase 2'); + handleMkcol(req, res).catch((err) => { + console.error('MKCOL unhandled:', err); + if (!res.headersSent) res.status(500).send(err.message); + }); return; } if (req.method === 'MOVE') { - res.status(501).send('MOVE noch nicht implementiert – Phase 2'); + handleMove(req, res).catch((err) => { + console.error('MOVE unhandled:', err); + if (!res.headersSent) res.status(500).send(err.message); + }); return; } next(); @@ -196,6 +396,6 @@ app.use((req, res, next) => { app.listen(PORT, () => { console.log(`Internxt WebDAV Server – http://127.0.0.1:${PORT}`); - console.log('Phase 1: PROPFIND (Verzeichnis auflisten) aktiv.'); + console.log('Phase 1+2: PROPFIND, MKCOL, DELETE, MOVE aktiv.'); console.log('Verwendung: z.B. Windows Explorer → Netzlaufwerk verbinden'); });