/** * WebDAV-Server für Internxt Drive (Phase 1–4: PROPFIND, MKCOL, DELETE, MOVE, GET, PUT) * * Nutzt Browser-Token (INXT_TOKEN, INXT_MNEMONIC) aus .env. * Siehe docs/browser-token-auth.md */ import 'dotenv/config'; import fs from 'fs'; import path from 'path'; import express from 'express'; import { createClients, refreshUser } from './internxt-client.js'; import { pathToSegments, segmentsToPath, listFolder, resolveFolder, resolveResource } from './path-resolver.js'; import { getPlainName } from './name-decrypt.js'; import { downloadFileStream } from './download.js'; import { uploadFileBuffer } from './upload.js'; const PORT = parseInt(process.env.PORT || '3005', 10); const token = process.env.INXT_TOKEN; const mnemonic = process.env.INXT_MNEMONIC; if (!token) { console.error('Fehler: INXT_TOKEN muss gesetzt sein. Siehe docs/browser-token-auth.md'); process.exit(1); } const LOG_DIR = path.join(process.cwd(), 'logs'); /** Schreibt in logs/webdav-debug.log (separate Datei, kein Konflikt mit stdout→webdav.log) */ function logToFile(...args) { const msg = args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n'; try { fs.mkdirSync(LOG_DIR, { recursive: true }); fs.appendFileSync(path.join(LOG_DIR, 'webdav-debug.log'), `[${new Date().toISOString()}] ${msg}`); } catch (_) {} } /** Schreibt Fehler in logs/webdav-errors.log (separate Datei, kein Konflikt mit stdout) */ function logError(...args) { const msg = args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n'; try { fs.mkdirSync(LOG_DIR, { recursive: true }); fs.appendFileSync(path.join(LOG_DIR, 'webdav-errors.log'), `[${new Date().toISOString()}] ${msg}`); } catch (e) { console.error('logError failed:', e.message); } } process.on('unhandledRejection', (reason, promise) => { logError('unhandledRejection', reason); }); process.on('uncaughtException', (err) => { logError('uncaughtException', err.message, err.stack); }); // Fehlerdatei beim Start anlegen – prüft, ob dieser Prozess die neue Version läuft logError('Server gestartet (Version mit Fehler-Logging)'); /** Cache für neu erstellte Dateien – rclone verifiziert per GET direkt nach PUT; API kann verzögert sein */ const recentFileCache = new Map(); const CACHE_TTL_MS = 60_000; function cacheRecentFile(pathKey, resource) { recentFileCache.set(pathKey, resource); setTimeout(() => recentFileCache.delete(pathKey), CACHE_TTL_MS); } const app = express(); // WebDAV-Credentials: Wenn gesetzt, werden Client-Credentials dagegen geprüft. // Sonst akzeptiert der Server beliebige Basic-Auth-Credentials (für Duplicati etc.). const webdavUser = process.env.WEBDAV_USER; const webdavPass = process.env.WEBDAV_PASS; const authStrict = webdavUser != null && webdavPass != null; /** * Basic-Auth-Middleware: Akzeptiert beliebige Credentials oder prüft gegen WEBDAV_USER/WEBDAV_PASS. */ function basicAuth(req, res, next) { if (req.method === 'OPTIONS') return next(); const auth = req.headers?.authorization; if (!auth || !auth.startsWith('Basic ')) { res.set('WWW-Authenticate', 'Basic realm="Internxt WebDAV"'); res.status(401).send('Authentifizierung erforderlich'); return; } let user, pass; try { const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); const colon = decoded.indexOf(':'); user = colon >= 0 ? decoded.slice(0, colon) : decoded; pass = colon >= 0 ? decoded.slice(colon + 1) : ''; } catch (_) { res.set('WWW-Authenticate', 'Basic realm="Internxt WebDAV"'); res.status(401).send('Ungültige Credentials'); return; } if (authStrict && (user !== webdavUser || pass !== webdavPass)) { res.set('WWW-Authenticate', 'Basic realm="Internxt WebDAV"'); res.status(401).send('Ungültige Credentials'); return; } req.webdavUser = user; next(); } app.use(basicAuth); app.use((req, res, next) => { if (req.url && req.url.includes('restic')) { logToFile('REQ', req.method, req.url); } const origSend = res.send; res.send = function (...args) { if (req.url && req.url.includes('restic')) { logToFile('RES', req.method, req.url, 'status:', res.statusCode); if (res.statusCode >= 400) logError('HTTP', res.statusCode, req.method, req.url); } return origSend.apply(this, args); }; next(); }); // Request-Body: PUT/POST als Raw (Datei-Upload), PROPFIND als Text app.use(express.raw({ type: (req) => req.method === 'PUT' || req.method === 'POST', limit: '1gb' })); app.use(express.text({ type: 'application/xml', limit: '1kb' })); /** * Formatiert Datum für DAV:getlastmodified (RFC 2822) */ function toLastModified(iso) { if (!iso) return 'Thu, 01 Jan 1970 00:00:00 GMT'; const d = new Date(iso); return d.toUTCString(); } /** Pfad ohne Query-String aus Request extrahieren */ function getPathFromRequest(req) { const url = req.url || '/'; const path = url.split('?')[0]; try { return decodeURIComponent(path); } catch (_) { return path; } } /** * Entfernt Null-Bytes und Steuerzeichen (verursacht "Null character in path" unter Windows). * Behält druckbare Zeichen: Space (0x20) bis ~ (0x7e), inkl. + = / für Base64. */ function sanitizeForPath(s) { if (s == null) return ''; return String(s) .replace(/\0/g, '') .replace(/[\x00-\x1f\x7f]/g, ''); } /** * Escaped XML-Text */ function escapeXml(s) { if (s == null) return ''; return sanitizeForPath(String(s)) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Erzeugt PROPFIND multistatus XML */ function buildPropfindResponse(baseUrl, items) { const ns = 'DAV:'; const responses = items.map((item) => { const safePath = sanitizeForPath(item.path); const pathForHref = safePath === '/' ? '/' : safePath .split('/') .filter(Boolean) .map((seg) => encodeURIComponent(seg)) .join('/') + (item.isCollection ? '/' : ''); const href = baseUrl + (pathForHref === '/' ? '/' : '/' + pathForHref); const lastMod = toLastModified(item.updatedAt); const resourcetype = item.isCollection ? `` : ''; const contentLength = item.isCollection ? '' : `${item.size ?? 0}`; return ` ${escapeXml(href)} ${escapeXml(item.name)} ${escapeXml(lastMod)} ${resourcetype} ${contentLength} HTTP/1.1 200 OK `; }); return ` ${responses.join('')} `; } /** * PROPFIND Handler */ async function handlePropfind(req, res) { const depth = req.headers['depth'] || '1'; let path = getPathFromRequest(req); if (!path.startsWith('/')) path = '/' + path; if (path !== '/' && path.endsWith('/')) path = path.slice(0, -1); path = sanitizeForPath(path); const baseUrl = `${req.protocol}://${req.get('host')}`; try { 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) { res.status(500).send('Root-Ordner nicht gefunden'); return; } // PROPFIND auf Datei (z.B. rclone-Verifizierung nach PUT) let resource = await resolveResource(storage, rootUuid, path); if (!resource) resource = recentFileCache.get(path); if (resource && resource.type === 'file') { const segments = pathToSegments(path); const fileName = segments[segments.length - 1] || 'file'; const items = [{ path, name: resource.name || fileName, isCollection: false, updatedAt: new Date().toISOString(), size: resource.size ?? 0, }]; const xml = buildPropfindResponse(baseUrl, items).replace(/\0/g, ''); res.set('Content-Type', 'application/xml; charset="utf-8"'); res.status(207).send(xml); return; } const listing = await listFolder(storage, rootUuid, path); if (!listing) { res.status(404).send('Nicht gefunden'); return; } const items = []; const segments = pathToSegments(path); const parentName = segments.length ? segments[segments.length - 1] : 'Drive'; // Aktuelle Ressource (Collection) items.push({ path, name: parentName, isCollection: true, updatedAt: null, size: null, }); // Kinder bei depth 1 if (depth !== '0') { for (const f of listing.folders) { const safeName = sanitizeForPath(f.name) || 'Unbenannt'; const childPath = path === '/' ? '/' + safeName : path + '/' + safeName; items.push({ path: childPath, name: safeName, isCollection: true, updatedAt: null, size: null, }); } for (const f of listing.files) { const rawName = sanitizeForPath(f.name) || 'Unbenannt'; const useUuidPath = /[+=]/.test(rawName) || rawName.length > 80; const pathSegment = useUuidPath ? `_.${f.uuid}` : rawName; const childPath = path === '/' ? '/' + pathSegment : path + '/' + pathSegment; items.push({ path: childPath, name: useUuidPath ? f.uuid : rawName, isCollection: false, updatedAt: f.updatedAt, size: f.size, uuid: f.uuid, }); } } let xml = buildPropfindResponse(baseUrl, items); xml = xml.replace(/\0/g, ''); // Sicherheitsnetz: keine Null-Bytes in der Antwort res.set('Content-Type', 'application/xml; charset="utf-8"'); res.status(207).send(xml); } catch (err) { console.error('PROPFIND Fehler:', err.message); if (err.message?.includes('Token') || err.response?.status === 401) { res.status(401).send('Nicht autorisiert – Token abgelaufen. Neu einloggen: https://drive.internxt.com'); return; } res.status(500).send(err.message || 'Interner Fehler'); } } /** * 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 }; } /** * Stellt sicher, dass ein Ordnerpfad existiert (erstellt fehlende Eltern rekursiv). * @returns {Promise<{ uuid: string } | null>} Ordner oder null */ async function ensureFolderExists(storage, rootUuid, path) { const segments = pathToSegments(path); let currentUuid = rootUuid; for (const segment of segments) { const [contentPromise] = storage.getFolderContentByUuid({ folderUuid: currentUuid }); const content = await contentPromise; const child = content?.children?.find((c) => { const name = getPlainName(c.name, c.plain_name ?? c.plainName, c.parent_id ?? c.parentId, null); return sanitizeForPath(name).toLowerCase() === sanitizeForPath(segment).toLowerCase(); }); if (child) { currentUuid = child.uuid; } else { const [createPromise] = storage.createFolderByUuid({ parentFolderUuid: currentUuid, plainName: segment, }); const created = await createPromise; currentUuid = created?.uuid; if (!currentUuid) return null; } } return { uuid: currentUuid }; } /** * MKCOL Handler – Ordner anlegen (rekursiv: fehlende Eltern werden erstellt) */ 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 = parentPath && parentPath !== '/' ? await ensureFolderExists(storage, rootUuid, parentPath) : { uuid: rootUuid }; if (!parent) { res.status(409).send('Übergeordneter Ordner existiert nicht'); return; } const existing = await resolveResource(storage, rootUuid, path); if (existing) { if (existing.type === 'folder') { res.status(201).send(); return; } res.status(405).send('Ressource existiert bereits (kein Ordner)'); return; } const [createPromise] = storage.createFolderByUuid({ parentFolderUuid: parent.uuid, plainName: newName, }); await createPromise; res.status(201).send(); } catch (err) { if (err?.message?.toLowerCase().includes('already exists')) { res.status(201).send(); return; } console.error('MKCOL Fehler:', err.message); if (err.message?.includes('Token') || err.response?.status === 401) { res.status(401).send('Nicht autorisiert – Token erneuern: https://drive.internxt.com'); 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 – Token erneuern: https://drive.internxt.com'); 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 – Token erneuern: https://drive.internxt.com'); return; } res.status(500).send(err.message || 'Interner Fehler'); } } /** * GET Handler – Datei herunterladen */ async function handleGet(req, res) { let path = getPathFromRequest(req); if (!path.startsWith('/')) path = '/' + path; if (path.endsWith('/')) path = path.slice(0, -1); path = sanitizeForPath(path); if (path === '/') { res.status(405).send('Verzeichnis kann nicht heruntergeladen werden'); return; } if (!mnemonic) { res.status(500).send('INXT_MNEMONIC fehlt für Datei-Entschlüsselung'); return; } try { const { storage, rootUuid } = await getContext(); let resource = await resolveResource(storage, rootUuid, path); if (!resource) { resource = recentFileCache.get(path); if (resource) logToFile('GET cache hit', path); } if (!resource) { res.status(404).send('Nicht gefunden'); return; } if (resource.type !== 'file') { res.status(405).send('Keine Datei'); return; } if (!resource.bucket || !resource.fileId) { res.status(404).send('Datei hat keinen Inhalt (leere Datei)'); return; } const refresh = await refreshUser(token); const user = refresh.user; const bridgeUser = user?.bridgeUser || user?.email; const bridgePass = user?.userId; if (!bridgeUser || !bridgePass) { res.status(500).send('Bridge-Credentials fehlen'); return; } const stream = await downloadFileStream({ bucketId: resource.bucket, fileId: resource.fileId, bridgeUser, bridgePass, mnemonic, }); res.set('Content-Disposition', `attachment; filename="${encodeURIComponent(resource.name)}"`); stream.pipe(res); stream.on('error', (err) => { if (!res.headersSent) res.status(500).send(err.message); else res.destroy(); }); } catch (err) { console.error('GET Fehler:', err.message); if (err.message?.includes('Token') || err.response?.status === 401) { res.status(401).send('Nicht autorisiert – Token erneuern: https://drive.internxt.com'); return; } if (!res.headersSent) res.status(500).send(err.message || 'Interner Fehler'); } } /** * HEAD Handler – wie GET, aber nur Header */ async function handleHead(req, res) { let path = getPathFromRequest(req); if (!path.startsWith('/')) path = '/' + path; if (path.endsWith('/')) path = path.slice(0, -1); path = sanitizeForPath(path); if (path === '/') { res.status(405).send(); return; } try { const { storage, rootUuid } = await getContext(); let resource = await resolveResource(storage, rootUuid, path); if (!resource) resource = recentFileCache.get(path); if (!resource) { res.status(404).send(); return; } if (resource.type !== 'file') { res.status(405).send(); return; } res.set('Content-Disposition', `attachment; filename="${encodeURIComponent(resource.name)}"`); res.status(200).send(); } catch (err) { if (!res.headersSent) res.status(500).send(); } } /** Parst Dateiname in plainName + Typ (Extension) */ function parseFileName(name) { if (!name || typeof name !== 'string') return { plainName: 'Unbenannt', type: '' }; const s = sanitizeForPath(name); const lastDot = s.lastIndexOf('.'); if (lastDot <= 0) return { plainName: s || 'Unbenannt', type: '' }; return { plainName: s.slice(0, lastDot) || 'Unbenannt', type: s.slice(lastDot + 1).toLowerCase() || '', }; } /** * PUT Handler – Datei hochladen */ async function handlePut(req, res) { let path = getPathFromRequest(req); if (!path.startsWith('/')) path = '/' + path; if (path.endsWith('/')) path = path.slice(0, -1); path = sanitizeForPath(path); if (process.env.DEBUG) { console.log('PUT', path, 'Content-Length:', req.headers['content-length'], 'Body:', req.body?.length ?? 0); } if (path === '/') { res.status(403).send('Root kann nicht überschrieben werden'); return; } const buffer = req.body; if (!Buffer.isBuffer(buffer)) { res.status(400).send('Kein Dateiinhalt erhalten'); return; } if (!mnemonic) { res.status(500).send('INXT_MNEMONIC fehlt für Datei-Verschlüsselung'); return; } try { logToFile('PUT try start', path); const { storage, rootUuid } = await getContext(); logToFile('PUT getContext OK', path); const segments = pathToSegments(path); const parentPath = segmentsToPath(segments.slice(0, -1)); const fileName = segments[segments.length - 1]; let parent = await resolveFolder(storage, rootUuid, parentPath); if (!parent && parentPath && parentPath !== '/') { parent = await ensureFolderExists(storage, rootUuid, parentPath); } if (!parent) { res.status(409).send('Zielordner existiert nicht'); return; } const existing = await resolveResource(storage, rootUuid, path); if (existing) { if (existing.type === 'file') { await storage.deleteFileByUuid(existing.uuid); } else { res.status(409).send('Ziel ist ein Ordner'); return; } } // Leere Dateien (z.B. Duplicati duplicati-access-privileges-test.tmp): Bridge braucht mind. 1 Byte const uploadBuffer = buffer.length > 0 ? buffer : Buffer.from([0]); const refresh = await refreshUser(token); const user = refresh.user; const bridgeUser = user?.bridgeUser || user?.email; const bridgePass = user?.userId; const bucketId = user?.bucket; if (!bridgeUser || !bridgePass || !bucketId) { res.status(500).send('Bridge-Credentials oder Bucket fehlen'); return; } const { plainName, type } = parseFileName(fileName); let fileId; logToFile('PUT Upload start', path); try { fileId = await uploadFileBuffer({ bucketId, bridgeUser, bridgePass, mnemonic, buffer: uploadBuffer, }); } catch (uploadErr) { logError('PUT Upload (Bridge) fehlgeschlagen', path, uploadErr.message); throw uploadErr; } logToFile('PUT Upload OK', path); const date = new Date().toISOString(); logToFile('PUT createFileEntry start', path); const doCreate = async () => { await storage.createFileEntryByUuid({ fileId, type: type || 'bin', size: buffer.length, plainName, bucket: bucketId, folderUuid: parent.uuid, encryptVersion: '03-aes', modificationTime: date, date, }); }; try { await doCreate(); logToFile('PUT createFileEntry OK', path); const fullName = type ? `${plainName}.${type}` : plainName; cacheRecentFile(path, { type: 'file', bucket: bucketId, fileId, name: fullName, size: buffer.length }); } catch (createErr) { logError('PUT createFileEntry fehlgeschlagen', path, createErr.message); // "File already exists" – Datei per Namen löschen und erneut versuchen if (createErr?.message?.toLowerCase().includes('already exists')) { const [contentPromise] = storage.getFolderContentByUuid({ folderUuid: parent.uuid }); const content = await contentPromise; const fullName = type ? `${plainName}.${type}` : plainName; const dup = (content?.files || []).find((f) => { const apiPlain = f.plain_name ?? f.plainName ?? getPlainName(f.name, null, null, f.folder_id ?? f.folderId); const apiType = f.type ?? ''; const apiFull = apiType ? `${apiPlain}.${apiType}` : apiPlain; return apiFull.toLowerCase() === fullName.toLowerCase(); }); if (dup) { await storage.deleteFileByUuid(dup.uuid); await doCreate(); } else { throw createErr; } } else { throw createErr; } } res.status(201).send(); } catch (err) { logError('PUT CATCH', path, err?.message ?? String(err), err?.response?.status, err?.response?.data); const apiErr = err.response?.data ? JSON.stringify(err.response.data) : ''; const status = err.response?.status; if (process.env.DEBUG) logError('Stack:', err.stack); console.error('PUT Fehler:', path, err.message, status ? `HTTP ${status}` : '', apiErr || ''); if (process.env.DEBUG) console.error(err.stack); if (err.message?.includes('Token') || err.response?.status === 401) { res.status(401).send('Nicht autorisiert – Token erneuern: https://drive.internxt.com'); return; } if (!res.headersSent) res.status(500).send(err.message || 'Interner Fehler'); } } // WebDAV Endpoints app.options('*', (req, res) => { res.set('DAV', '1, 2'); res.set('Allow', 'OPTIONS, PROPFIND, GET, HEAD, PUT, POST, DELETE, MKCOL, MOVE'); res.sendStatus(200); }); // Alle WebDAV-Methoden zentral app.use((req, res, next) => { if (req.method === 'PROPFIND') { handlePropfind(req, res); return; } if (req.method === 'GET') { handleGet(req, res).catch((err) => { console.error('GET unhandled:', err); if (!res.headersSent) res.status(500).send(err.message); }); return; } if (req.method === 'HEAD') { handleHead(req, res).catch((err) => { console.error('HEAD unhandled:', err); if (!res.headersSent) res.status(500).send(err.message); }); return; } if (req.method === 'PUT' || req.method === 'POST') { handlePut(req, res).catch((err) => { logError('PUT unhandled', err?.message, err?.stack); if (!res.headersSent) res.status(500).send(err.message); }); return; } if (req.method === 'DELETE') { handleDelete(req, res).catch((err) => { console.error('DELETE unhandled:', err); if (!res.headersSent) res.status(500).send(err.message); }); return; } if (req.method === 'MKCOL') { handleMkcol(req, res).catch((err) => { console.error('MKCOL unhandled:', err); if (!res.headersSent) res.status(500).send(err.message); }); return; } if (req.method === 'MOVE') { handleMove(req, res).catch((err) => { console.error('MOVE unhandled:', err); if (!res.headersSent) res.status(500).send(err.message); }); return; } next(); }); app.listen(PORT, () => { console.log(`Internxt WebDAV Server – http://127.0.0.1:${PORT}`); console.log('Phase 1–4: PROPFIND, MKCOL, DELETE, MOVE, GET, PUT aktiv.'); console.log(`rclone/restic: URL muss http://127.0.0.1:${PORT} sein (gleicher Port!)`); console.log('Verwendung: z.B. Windows Explorer → Netzlaufwerk verbinden'); });