Files
internxt-webdav/src/server.js
elpatron b463579896 restic/rclone: PROPFIND für Dateien, MKCOL-Fix, Logging, Cache
- PROPFIND auf Dateipfade (rclone-Verifizierung nach PUT)
- MKCOL: 'already exists' -> 201 statt 500
- resolveResource: name.bin-Fallback für Dateien ohne Erweiterung
- recentFileCache für neu erstellte Dateien (API-Verzögerung)
- Logging: webdav-debug.log, webdav-errors.log, REQ/RES
- start-webdav.cmd: Log-Ausgabe in Datei, PORT aus .env
- Troubleshooting-Doku für restic 500-Fehler

Made-with: Cursor
2026-02-28 16:11:22 +01:00

882 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WebDAV-Server für Internxt Drive (Phase 14: 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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
? `<D:resourcetype><D:collection/></D:resourcetype>`
: '<D:resourcetype/>';
const contentLength = item.isCollection ? '' : `<D:getcontentlength>${item.size ?? 0}</D:getcontentlength>`;
return `
<D:response>
<D:href>${escapeXml(href)}</D:href>
<D:propstat>
<D:prop>
<D:displayname>${escapeXml(item.name)}</D:displayname>
<D:getlastmodified>${escapeXml(lastMod)}</D:getlastmodified>
${resourcetype}
${contentLength}
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`;
});
return `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="${ns}">${responses.join('')}
</D:multistatus>`;
}
/**
* 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 14: 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');
});