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

129
src/auth-poc.js Normal file
View File

@@ -0,0 +1,129 @@
/**
* Proof-of-Concept: Internxt Login mit clientName "drive-web"
*
* Testet ob Login mit Web-Client-Identität für eingeschränkte Account-Tiers funktioniert.
* CLI und Rclone nutzen loginAccess() mit "internxt-cli" - das wird blockiert.
* drive-web nutzt login() mit "drive-web" - das funktioniert.
*
* Ausführung: INXT_EMAIL=... INXT_PASSWORD=... node src/auth-poc.js
* Oder: .env mit Credentials füllen, dann node src/auth-poc.js
*/
import 'dotenv/config';
import { Auth } from '@internxt/sdk/dist/auth/index.js';
import CryptoJS from 'crypto-js';
const DRIVE_API_URL = process.env.DRIVE_API_URL || 'https://gateway.internxt.com/drive';
const CRYPTO_SECRET = process.env.CRYPTO_SECRET || process.env.APP_CRYPTO_SECRET || '6KYQBP847D4ATSFA';
const email = process.env.INXT_EMAIL;
const password = process.env.INXT_PASSWORD;
const twoFactorCode = process.env.INXT_2FA || '';
if (!email || !password) {
console.error('Fehler: INXT_EMAIL und INXT_PASSWORD müssen gesetzt sein.');
process.exit(1);
}
// CryptoProvider wie in drive-web - passToHash und encryptText
function passToHash({ password, salt }) {
const saltParsed = salt ? CryptoJS.enc.Hex.parse(salt) : CryptoJS.lib.WordArray.random(128 / 8);
const hash = CryptoJS.PBKDF2(password, saltParsed, { keySize: 256 / 32, iterations: 10000 });
return {
salt: saltParsed.toString(),
hash: hash.toString(),
};
}
function encryptText(text) {
const bytes = CryptoJS.AES.encrypt(text, CRYPTO_SECRET).toString();
const text64 = CryptoJS.enc.Base64.parse(bytes);
return text64.toString(CryptoJS.enc.Hex);
}
function decryptText(encryptedText) {
const reb = CryptoJS.enc.Hex.parse(encryptedText);
const bytes = CryptoJS.AES.decrypt(reb.toString(CryptoJS.enc.Base64), CRYPTO_SECRET);
return bytes.toString(CryptoJS.enc.Utf8);
}
const cryptoProvider = {
encryptPasswordHash(password, encryptedSalt) {
const salt = decryptText(encryptedSalt);
const hashObj = passToHash({ password, salt });
return encryptText(hashObj.hash);
},
async generateKeys() {
throw new Error('generateKeys not used - loginWithoutKeys');
},
};
// AppDetails mit clientName "drive-web" - der Schlüssel zum Bypass!
const appDetails = {
clientName: 'drive-web',
clientVersion: '1.0',
};
const apiSecurity = {
token: '',
unauthorizedCallback: () => {},
};
async function main() {
console.log('Internxt Auth PoC - Login mit clientName "drive-web"');
console.log('API:', DRIVE_API_URL);
console.log('E-Mail:', email);
console.log('2FA:', twoFactorCode ? '***' + twoFactorCode.slice(-2) : '(nicht gesetzt)');
console.log('');
const authClient = Auth.client(DRIVE_API_URL, appDetails, apiSecurity);
// Optional: securityDetails prüfen (Salt-Decryption testet CRYPTO_SECRET)
if (process.env.DEBUG) {
try {
const details = await authClient.securityDetails(email.toLowerCase());
const salt = decryptText(details.encryptedSalt);
const isHex = /^[0-9a-f]+$/i.test(salt);
console.log('DEBUG: Salt-Decryption OK, Format:', isHex ? 'Hex' : 'anderes');
} catch (e) {
console.error('DEBUG: Salt-Decryption fehlgeschlagen - CRYPTO_SECRET evtl. falsch:', e.message);
}
}
try {
const result = await authClient.loginWithoutKeys(
{
email: email.toLowerCase(),
password,
tfaCode: twoFactorCode || undefined,
},
cryptoProvider
);
console.log('Login erfolgreich!');
console.log('Token:', result.newToken?.substring(0, 20) + '...');
console.log('User:', result.user?.email);
console.log('');
console.log('Der WebDAV-Wrapper kann mit dieser Auth gebaut werden.');
} catch (err) {
console.error('Login fehlgeschlagen:', err.message);
if (err.response?.data) {
console.error('Response:', JSON.stringify(err.response.data, null, 2));
}
if (err.message?.includes('cli access not allowed') || err.message?.includes('rclone access not allowed')) {
console.error('');
console.error('Hinweis: Dieser Fehler sollte mit clientName "drive-web" NICHT auftreten.');
}
if (err.message?.includes('Wrong login credentials')) {
console.error('');
console.error('Mögliche Ursachen:');
console.error('1. CRYPTO_SECRET falsch - drive-web nutzt REACT_APP_CRYPTO_SECRET (evtl. anderer Wert)');
console.error(' -> DEBUG=1 setzen und erneut ausführen, um Salt-Decryption zu prüfen');
console.error('2. 2FA-Code abgelaufen (30s gültig) - neuen Code eingeben');
console.error('3. Passwort/E-Mail falsch');
}
process.exit(1);
}
}
main();

41
src/internxt-client.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* Internxt API Client Drive API + User Refresh
* Nutzt Browser-Token (INXT_TOKEN) für drive-web Endpoints.
*/
import 'dotenv/config';
import { Storage, Users } from '@internxt/sdk/dist/drive/index.js';
const DRIVE_API_URL = process.env.DRIVE_API_URL || 'https://gateway.internxt.com/drive';
const appDetails = { clientName: 'drive-web', clientVersion: '1.0' };
/**
* Erstellt API-Clients mit aktuellem Token.
* @param {string} token - Bearer Token (xNewToken)
* @returns {{ users: Users, storage: Storage }}
*/
export function createClients(token) {
const apiSecurity = {
token,
unauthorizedCallback: () => {
throw new Error('Token abgelaufen oder ungültig');
},
};
return {
users: Users.client(DRIVE_API_URL, appDetails, apiSecurity),
storage: Storage.client(DRIVE_API_URL, appDetails, apiSecurity),
};
}
/**
* Holt User-Daten inkl. Bridge-Credentials via refreshUser.
* @param {string} token
* @returns {Promise<{ user: object, newToken?: string }>}
*/
export async function refreshUser(token) {
const { users } = createClients(token);
const response = await users.refreshUser();
const user = response?.user ?? response;
return { user, newToken: response?.newToken };
}

72
src/path-resolver.js Normal file
View File

@@ -0,0 +1,72 @@
/**
* WebDAV-Pfad ↔ Internxt-UUID Auflösung
*/
/**
* Normalisiert WebDAV-Pfad zu Segmenten (ohne führendes /).
* "/" -> [], "/folder1" -> ["folder1"], "/folder1/sub" -> ["folder1","sub"]
*/
export function pathToSegments(path) {
const p = String(path || '').replace(/\/+/g, '/').replace(/^\//, '').replace(/\/$/, '');
return p ? p.split('/') : [];
}
/**
* Baut Pfad aus Segmenten.
*/
export function segmentsToPath(segments) {
return '/' + (Array.isArray(segments) ? segments.join('/') : '');
}
/**
* Resolved einen WebDAV-Pfad zur Internxt-Ordner-UUID.
* @param {object} storage - Storage Client
* @param {string} rootFolderUuid - Root-Ordner-UUID
* @param {string} path - WebDAV-Pfad (z.B. "/" oder "/folder1/sub")
* @returns {Promise<{ uuid: string, type: 'folder', name: string } | null>}
*/
export async function resolveFolder(storage, rootFolderUuid, path) {
const segments = pathToSegments(path);
let currentUuid = rootFolderUuid;
for (const segment of segments) {
const [contentPromise] = storage.getFolderContentByUuid({ folderUuid: currentUuid });
const content = await contentPromise;
const child = content?.children?.find(
(c) => (c.plain_name || c.name || '').toLowerCase() === segment.toLowerCase()
);
if (!child) return null;
currentUuid = child.uuid;
}
const name = segments.length ? segments[segments.length - 1] : '';
return { uuid: currentUuid, type: 'folder', name: name || 'Drive' };
}
/**
* Listet Ordnerinhalt (Unterordner + Dateien) für einen Pfad.
* @param {object} storage
* @param {string} rootFolderUuid
* @param {string} path
* @returns {Promise<{ folders: Array<{uuid, name}>, files: Array<{uuid, name, size, updatedAt}> }>}
*/
export async function listFolder(storage, rootFolderUuid, path) {
const folder = await resolveFolder(storage, rootFolderUuid, path);
if (!folder) return null;
const [contentPromise] = storage.getFolderContentByUuid({ folderUuid: folder.uuid });
const content = await contentPromise;
const folders = (content?.children || []).map((c) => ({
uuid: c.uuid,
name: c.plain_name || c.name || 'Unbenannt',
}));
const files = (content?.files || []).map((f) => ({
uuid: f.uuid,
name: f.plain_name || f.name || f.plainName || 'Unbenannt',
size: f.size || 0,
updatedAt: f.updatedAt || f.modificationTime || f.creationTime,
}));
return { folders, files };
}

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, '&amp;')
.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');
});

59
src/token-test.js Normal file
View File

@@ -0,0 +1,59 @@
/**
* Token-Test: Prüft ob Browser-Token funktioniert
*
* Voraussetzung: Auf drive.internxt.com eingeloggt, Token + Mnemonic aus
* localStorage (xNewToken, xMnemonic) in .env als INXT_TOKEN und INXT_MNEMONIC
*
* Siehe docs/browser-token-auth.md
*/
import 'dotenv/config';
import { Storage, Users } from '@internxt/sdk/dist/drive/index.js';
const DRIVE_API_URL = process.env.DRIVE_API_URL || 'https://gateway.internxt.com/drive';
const token = process.env.INXT_TOKEN;
if (!token) {
console.error('Fehler: INXT_TOKEN muss gesetzt sein (aus Browser localStorage)');
process.exit(1);
}
const appDetails = { clientName: 'drive-web', clientVersion: '1.0' };
const apiSecurity = {
token,
unauthorizedCallback: () => console.error('Token abgelaufen oder ungültig'),
};
async function main() {
console.log('Token-Test Drive API mit Browser-Token');
console.log('');
const usersClient = Users.client(DRIVE_API_URL, appDetails, apiSecurity);
const storageClient = Storage.client(DRIVE_API_URL, appDetails, apiSecurity);
try {
// refreshUser nutzt /users/refresh (nicht CLI-Endpoint)
const response = await usersClient.refreshUser();
const user = response?.user ?? response;
console.log('Token OK User:', user?.email);
console.log('Root Folder UUID:', user?.rootFolderId || user?.rootFolderUuid);
const rootUuid = user?.rootFolderUuid || user?.rootFolderId;
if (rootUuid) {
const [content] = storageClient.getFolderContentByUuid({ folderUuid: rootUuid });
const folderContent = await content;
console.log('Dateien/Ordner im Root:', folderContent.children?.length ?? 0);
}
console.log('');
console.log('Token funktioniert WebDAV-Server kann gebaut werden.');
} catch (err) {
console.error('Fehler:', err.message);
if (err.response?.status === 401) {
console.error('Token abgelaufen bitte erneut auf drive.internxt.com einloggen und Token aktualisieren.');
}
process.exit(1);
}
}
main();