/**
* 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');
});