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:
129
src/auth-poc.js
Normal file
129
src/auth-poc.js
Normal 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
41
src/internxt-client.js
Normal 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
72
src/path-resolver.js
Normal 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
201
src/server.js
Normal 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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
59
src/token-test.js
Normal 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();
|
||||
Reference in New Issue
Block a user