Namensentschlüsselung, Phase 4 PUT, Bridge-Fix, Debug-Tools

- name-decrypt.js: AES-Entschlüsselung für Datei-/Ordnernamen (CRYPTO_SECRET2)
- path-resolver.js: getPlainName für alle Pfad-Operationen
- upload.js: PUT mit Verschlüsselung, Bridge API v2
- download.js: Bridge 400-Fix (x-api-version, Header)
- debug-name-decrypt.js: Test-Skript für Namensentschlüsselung
- docs: CRYPTO_SECRET/CRYPTO_SECRET2 dokumentiert

Made-with: Cursor
This commit is contained in:
2026-02-28 11:49:26 +01:00
parent 43b814d984
commit 406e31f338
13 changed files with 932 additions and 83 deletions

View File

@@ -1,5 +1,5 @@
/**
* WebDAV-Server für Internxt Drive (Phase 1+2: PROPFIND, MKCOL, DELETE, MOVE)
* 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
@@ -9,9 +9,12 @@ import 'dotenv/config';
import express from 'express';
import { createClients, refreshUser } from './internxt-client.js';
import { pathToSegments, segmentsToPath, listFolder, resolveFolder, resolveResource } from './path-resolver.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');
@@ -20,7 +23,8 @@ if (!token) {
const app = express();
// Request-Body für PROPFIND
// Request-Body: PUT als Raw (Datei-Upload), PROPFIND als Text
app.use(express.raw({ type: (req) => req.method === 'PUT', limit: '1gb' }));
app.use(express.text({ type: 'application/xml', limit: '1kb' }));
/**
@@ -32,12 +36,34 @@ function toLastModified(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 String(s)
return sanitizeForPath(String(s))
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
@@ -51,7 +77,16 @@ function escapeXml(s) {
function buildPropfindResponse(baseUrl, items) {
const ns = 'DAV:';
const responses = items.map((item) => {
const href = baseUrl + (item.path === '/' ? '/' : item.path + (item.isCollection ? '/' : ''));
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>`
@@ -83,12 +118,10 @@ function buildPropfindResponse(baseUrl, items) {
*/
async function handlePropfind(req, res) {
const depth = req.headers['depth'] || '1';
let path = req.url || '/';
try {
path = decodeURIComponent(path);
} catch (_) {}
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')}`;
@@ -124,28 +157,34 @@ async function handlePropfind(req, res) {
// Kinder bei depth 1
if (depth !== '0') {
for (const f of listing.folders) {
const childPath = path === '/' ? '/' + f.name : path + '/' + f.name;
const safeName = sanitizeForPath(f.name) || 'Unbenannt';
const childPath = path === '/' ? '/' + safeName : path + '/' + safeName;
items.push({
path: childPath,
name: f.name,
name: safeName,
isCollection: true,
updatedAt: null,
size: null,
});
}
for (const f of listing.files) {
const childPath = path === '/' ? '/' + f.name : path + '/' + f.name;
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: f.name,
name: useUuidPath ? f.uuid : rawName,
isCollection: false,
updatedAt: f.updatedAt,
size: f.size,
uuid: f.uuid,
});
}
}
const xml = buildPropfindResponse(baseUrl, items);
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) {
@@ -349,6 +388,212 @@ async function handleMove(req, res) {
}
}
/**
* GET Handler Datei herunterladen
*/
async function handleGet(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(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();
const resource = await resolveResource(storage, rootUuid, 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');
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 = req.url || '/';
try {
path = decodeURIComponent(path);
} catch (_) {}
if (!path.startsWith('/')) path = '/' + path;
if (path.endsWith('/')) path = path.slice(0, -1);
if (path === '/') {
res.status(405).send();
return;
}
try {
const { storage, rootUuid } = await getContext();
const resource = await resolveResource(storage, rootUuid, 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 (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 {
const { storage, rootUuid } = await getContext();
const segments = pathToSegments(path);
const parentPath = segmentsToPath(segments.slice(0, -1));
const fileName = segments[segments.length - 1];
const parent = await resolveFolder(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;
}
}
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);
const fileId = await uploadFileBuffer({
bucketId,
bridgeUser,
bridgePass,
mnemonic,
buffer,
});
const date = new Date().toISOString();
await storage.createFileEntryByUuid({
fileId,
type: type || 'bin',
size: buffer.length,
plainName,
bucket: bucketId,
folderUuid: parent.uuid,
encryptVersion: '03-aes',
modificationTime: date,
date,
});
res.status(201).send();
} catch (err) {
console.error('PUT Fehler:', err.message);
if (err.message?.includes('Token') || err.response?.status === 401) {
res.status(401).send('Nicht autorisiert');
return;
}
if (!res.headersSent) res.status(500).send(err.message || 'Interner Fehler');
}
}
// WebDAV Endpoints
app.options('*', (req, res) => {
res.set('DAV', '1, 2');
@@ -362,12 +607,25 @@ app.use((req, res, next) => {
handlePropfind(req, res);
return;
}
if (req.method === 'GET' || req.method === 'HEAD') {
res.status(501).send('GET (Download) noch nicht implementiert Phase 3');
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') {
res.status(501).send('PUT (Upload) noch nicht implementiert Phase 4');
handlePut(req, res).catch((err) => {
console.error('PUT unhandled:', err);
if (!res.headersSent) res.status(500).send(err.message);
});
return;
}
if (req.method === 'DELETE') {
@@ -396,6 +654,6 @@ app.use((req, res, next) => {
app.listen(PORT, () => {
console.log(`Internxt WebDAV Server http://127.0.0.1:${PORT}`);
console.log('Phase 1+2: PROPFIND, MKCOL, DELETE, MOVE aktiv.');
console.log('Phase 14: PROPFIND, MKCOL, DELETE, MOVE, GET, PUT aktiv.');
console.log('Verwendung: z.B. Windows Explorer → Netzlaufwerk verbinden');
});