Initial commit: WebDAV-Adapter für Internxt Drive

- Browser-Token-Auth (INXT_TOKEN, INXT_MNEMONIC)
- Phase 1: PROPFIND (Verzeichnis auflisten)
- Drive API + Pfad-Resolver
- Dokumentation: Auth, Architektur, WSL

Made-with: Cursor
This commit is contained in:
2026-02-28 10:54:29 +01:00
commit 7c1866e6fc
15 changed files with 2170 additions and 0 deletions

201
src/server.js Normal file
View File

@@ -0,0 +1,201 @@
/**
* WebDAV-Server für Internxt Drive (Phase 1: PROPFIND)
*
* Nutzt Browser-Token (INXT_TOKEN, INXT_MNEMONIC) aus .env.
* Siehe docs/browser-token-auth.md
*/
import 'dotenv/config';
import express from 'express';
import { createClients, refreshUser } from './internxt-client.js';
import { pathToSegments, listFolder } from './path-resolver.js';
const PORT = parseInt(process.env.PORT || '3005', 10);
const token = process.env.INXT_TOKEN;
if (!token) {
console.error('Fehler: INXT_TOKEN muss gesetzt sein. Siehe docs/browser-token-auth.md');
process.exit(1);
}
const app = express();
// Request-Body für PROPFIND
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();
}
/**
* Escaped XML-Text
*/
function escapeXml(s) {
if (s == null) return '';
return 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 href = baseUrl + (item.path === '/' ? '/' : item.path + (item.isCollection ? '/' : ''));
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 = req.url || '/';
try {
path = decodeURIComponent(path);
} catch (_) {}
if (!path.startsWith('/')) path = '/' + path;
if (path !== '/' && path.endsWith('/')) path = path.slice(0, -1);
const baseUrl = `${req.protocol}://${req.get('host')}`;
try {
const { users, 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;
}
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 childPath = path === '/' ? '/' + f.name : path + '/' + f.name;
items.push({
path: childPath,
name: f.name,
isCollection: true,
updatedAt: null,
size: null,
});
}
for (const f of listing.files) {
const childPath = path === '/' ? '/' + f.name : path + '/' + f.name;
items.push({
path: childPath,
name: f.name,
isCollection: false,
updatedAt: f.updatedAt,
size: f.size,
});
}
}
const xml = buildPropfindResponse(baseUrl, items);
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 erneuern');
return;
}
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, 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' || req.method === 'HEAD') {
res.status(501).send('GET (Download) noch nicht implementiert Phase 3');
return;
}
if (req.method === 'PUT') {
res.status(501).send('PUT (Upload) noch nicht implementiert Phase 4');
return;
}
if (req.method === 'DELETE') {
res.status(501).send('DELETE noch nicht implementiert Phase 2');
return;
}
if (req.method === 'MKCOL') {
res.status(501).send('MKCOL noch nicht implementiert Phase 2');
return;
}
if (req.method === 'MOVE') {
res.status(501).send('MOVE noch nicht implementiert Phase 2');
return;
}
next();
});
app.listen(PORT, () => {
console.log(`Internxt WebDAV Server http://127.0.0.1:${PORT}`);
console.log('Phase 1: PROPFIND (Verzeichnis auflisten) aktiv.');
console.log('Verwendung: z.B. Windows Explorer → Netzlaufwerk verbinden');
});