- 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
882 lines
27 KiB
JavaScript
882 lines
27 KiB
JavaScript
/**
|
||
* 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, '"')
|
||
.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
|
||
? `<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 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');
|
||
});
|