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:
292
src/server.js
292
src/server.js
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* WebDAV-Server für Internxt Drive (Phase 1+2: PROPFIND, MKCOL, DELETE, MOVE)
|
||||
* 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
|
||||
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
@@ -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 1–4: PROPFIND, MKCOL, DELETE, MOVE, GET, PUT aktiv.');
|
||||
console.log('Verwendung: z.B. Windows Explorer → Netzlaufwerk verbinden');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user