diff --git a/.env.example b/.env.example index dc4ea0f..7cb852d 100644 --- a/.env.example +++ b/.env.example @@ -11,11 +11,12 @@ CRYPTO_SECRET=6KYQBP847D4ATSFA # DEBUG=1 # Salt-Decryption testen (ob CRYPTO_SECRET stimmt) -# Test credentials (für auth-poc.js) -INXT_EMAIL= -INXT_PASSWORD= -# INXT_2FA= # Bei 2FA: aktuellen Code eingeben - # Browser-Token (für token-test.js und WebDAV) – aus drive.internxt.com localStorage # INXT_TOKEN= # xNewToken # INXT_MNEMONIC= # xMnemonic (für Datei-Entschlüsselung) + +# WebDAV-Credentials (für Duplicati, Explorer etc.) +# Ohne Angabe: beliebige Basic-Auth-Credentials werden akzeptiert. +# Mit Angabe: nur diese Credentials werden akzeptiert. +# WEBDAV_USER= +# WEBDAV_PASS= diff --git a/.gitignore b/.gitignore index 445d470..317d296 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ Thumbs.db # Build / Cache dist/ .cache/ + +# Externe Repos (nur Referenz) +drive-web/ diff --git a/README.md b/README.md index a390842..dbf3c5b 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,54 @@ # Internxt WebDAV Wrapper -WebDAV-Zugang zu Internxt Drive für Account-Tiers, die keinen CLI- oder Rclone-Zugang haben. +WebDAV-Zugang zu Internxt Drive für Account-Tiers ohne CLI- oder Rclone-Native-Zugang. ## Hintergrund -Internxt blockiert für bestimmte Account-Typen (z.B. Free, Partner-Accounts) den Zugang über: -- Internxt CLI (`cli access not allowed for this user tier`) -- Rclone Native-Backend (`rclone access not allowed for this user tier`, Status 402) -- Docker-Image `internxt/webdav` (nutzt dieselbe Auth) +Internxt blockiert für bestimmte Account-Typen (z.B. Free, Partner) den Zugang über CLI und Rclone. -**Lösung:** Das Web-UI (drive.internxt.com) funktioniert – es nutzt `login()` mit `clientName: "drive-web"`. Der Wrapper imitiert diese Auth. +**Lösung:** Das Web-UI (drive.internxt.com) funktioniert – es nutzt `clientName: "drive-web"`. Dieser Wrapper imitiert diese Auth und bietet einen WebDAV-Server. -## Auth-Proof-of-Concept +## Schnellstart -1. Abhängigkeiten installieren: - ```bash - npm install - ``` +```bash +npm install +cp .env.example .env +# .env: INXT_TOKEN, INXT_MNEMONIC, CRYPTO_SECRET eintragen (siehe docs/browser-token-auth.md) +npm start +``` -2. `.env` anlegen (von `.env.example` kopieren): - ```bash - cp .env.example .env - ``` +Server läuft auf `http://127.0.0.1:3005`. -3. Credentials eintragen: - ``` - INXT_EMAIL=deine@email.de - INXT_PASSWORD=dein_passwort - INXT_2FA=123456 # Falls 2FA aktiv - ``` +## WebDAV-Funktionen -4. Auth testen: - ```bash - npm run auth-test - ``` +- **PROPFIND** – Verzeichnis auflisten +- **MKCOL** – Ordner erstellen +- **DELETE** – Dateien/Ordner löschen +- **MOVE** – Verschieben/Umbenennen +- **GET** – Dateien herunterladen +- **PUT** – Dateien hochladen -Bei Erfolg erscheint „Login erfolgreich!“ – dann kann der WebDAV-Server gebaut werden. +## Clients + +- **Duplicati** – Backup-Destination +- **rclone** – `rclone config` → WebDAV, URL `http://127.0.0.1:3005` +- **Windows Explorer** – Netzlaufwerk verbinden ## Dokumentation -- [docs/auth-analysis.md](docs/auth-analysis.md) – Analyse Web vs CLI Auth, clientName-Unterschied +| Datei | Beschreibung | +|-------|---------------| +| [docs/browser-token-auth.md](docs/browser-token-auth.md) | Token aus Browser extrahieren, WebDAV-Credentials | +| [docs/webdav-architektur.md](docs/webdav-architektur.md) | Architektur-Übersicht | +| [docs/wsl-setup.md](docs/wsl-setup.md) | WSL-Setup (login mit Keys) | +| [docs/auth-analysis.md](docs/auth-analysis.md) | Analyse Web vs CLI Auth | +| [docs/crypto-secret-extract.md](docs/crypto-secret-extract.md) | CRYPTO_SECRET aus drive.internxt.com ermitteln | -## Browser-Token-Auth (Alternative) +## Scripts -Falls der API-Login blockiert ist (z.B. Partner-Account): - -1. Auf https://drive.internxt.com einloggen -2. DevTools (F12) → Console: `localStorage.getItem('xNewToken')` und `localStorage.getItem('xMnemonic')` ausführen -3. Werte in `.env` als `INXT_TOKEN` und `INXT_MNEMONIC` eintragen -4. Testen: `npm run token-test` - -Details: [docs/browser-token-auth.md](docs/browser-token-auth.md) - -## WSL (login mit Keys) - -Unter Windows schlägt Kyber-WASM fehl. Unter WSL: [docs/wsl-setup.md](docs/wsl-setup.md) - -## Nächste Schritte - -- WebDAV-Server mit Token-Auth implementieren -- Storage-Client für Datei-Operationen anbinden +| Befehl | Beschreibung | +|-------|--------------| +| `npm start` | WebDAV-Server starten | +| `npm run token-test` | Token prüfen | +| `npm run auth-test` | API-Login testen (E-Mail/Passwort) | +| `npm run debug-names` | Namensentschlüsselung testen | diff --git a/docs/browser-token-auth.md b/docs/browser-token-auth.md index b5d46a1..506c608 100644 --- a/docs/browser-token-auth.md +++ b/docs/browser-token-auth.md @@ -52,10 +52,16 @@ console.log('Mnemonic:', localStorage.getItem('xMnemonic') || '(nicht gefunden)' INXT_TOKEN=eyJhbGciOiJIUzI1NiIs... INXT_MNEMONIC=word1 word2 word3 ... # Namensentschlüsselung: CRYPTO_SECRET oder CRYPTO_SECRET2 (CLI-Default: 6KYQBP847D4ATSFA) -# Falls Datei-/Ordnernamen verschlüsselt angezeigt werden: CRYPTO_SECRET2 aus drive.internxt.com extrahieren CRYPTO_SECRET=6KYQBP847D4ATSFA +# Optional: WebDAV-Credentials erzwingen (sonst beliebige Credentials akzeptiert) +# WEBDAV_USER=backup +# WEBDAV_PASS=geheim ``` +## WebDAV-Credentials (für Duplicati, Explorer) + +Der Server erwartet **Basic Auth**. Ohne `WEBDAV_USER`/`WEBDAV_PASS` in `.env` akzeptiert er **beliebige** Credentials – Sie können in Duplicati z.B. Benutzername `backup` und Passwort `geheim` eintragen. Mit `WEBDAV_USER` und `WEBDAV_PASS` werden nur diese Credentials akzeptiert. + ## WebDAV-Server starten ```bash diff --git a/package-lock.json b/package-lock.json index d58b4bd..f0bbf00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,7 @@ "bip39": "^3.1.0", "crypto-js": "^4.1.1", "dotenv": "^16.0.0", - "express": "^4.18.0", - "webdav-server": "^2.6.0" + "express": "^4.18.0" } }, "node_modules/@internxt/lib": { @@ -964,15 +963,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -1166,43 +1156,6 @@ "engines": { "node": ">= 0.8" } - }, - "node_modules/webdav-server": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/webdav-server/-/webdav-server-2.6.2.tgz", - "integrity": "sha512-0iHdrOzlKGFD96bTvPF8IIEfxw9Q7jB5LqWqhjyBYsofD6T6mOYqWtAvR88VY9Mq0xeg8bCRHC2Vifc9iuTYuw==", - "license": "Unlicense", - "dependencies": { - "mime-types": "^2.1.18", - "xml-js-builder": "^1.0.3" - }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/xml-js": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", - "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", - "license": "MIT", - "dependencies": { - "sax": "^1.2.4" - }, - "bin": { - "xml-js": "bin/cli.js" - } - }, - "node_modules/xml-js-builder": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/xml-js-builder/-/xml-js-builder-1.0.3.tgz", - "integrity": "sha512-BoLgG/glT45M0jK5PGh9h+iGrQxa8jJk9ofR63GroRifl2tbGB3/yYiVY3wQWHrZgWWfl9+7fhEB/VoD9mWnSg==", - "license": "Unlicense", - "dependencies": { - "xml-js": "^1.6.2" - }, - "engines": { - "node": ">= 4" - } } } } diff --git a/package.json b/package.json index 5347ae4..76e1d13 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "bip39": "^3.1.0", "crypto-js": "^4.1.1", "dotenv": "^16.0.0", - "express": "^4.18.0", - "webdav-server": "^2.6.0" + "express": "^4.18.0" } } diff --git a/src/bridge-test.js b/src/bridge-test.js deleted file mode 100644 index 0783093..0000000 --- a/src/bridge-test.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Bridge-Test: Ruft die Bridge API direkt auf - * Hilft beim Debuggen von 400/401 Fehlern - * - * Aufruf: node src/bridge-test.js - */ - -import 'dotenv/config'; -import crypto from 'crypto'; -import { Users } from '@internxt/sdk/dist/drive/index.js'; - -const BRIDGE_URL = process.env.BRIDGE_URL || process.env.STORJ_BRIDGE || 'https://gateway.internxt.com/network'; -const DRIVE_API_URL = process.env.DRIVE_API_URL || 'https://gateway.internxt.com/drive'; -const token = process.env.INXT_TOKEN; - -async function sha256Hex(data) { - return crypto.createHash('sha256').update(String(data)).digest('hex'); -} - -async function main() { - if (!token) { - console.error('INXT_TOKEN fehlt'); - process.exit(1); - } - - const users = Users.client(DRIVE_API_URL, { clientName: 'drive-web', clientVersion: '1.0' }, { - token, - unauthorizedCallback: () => { - throw new Error('Token ungültig'); - }, - }); - - const refresh = await users.refreshUser(); - const user = refresh?.user ?? refresh; - const bridgeUser = user?.bridgeUser || user?.email; - const bridgePass = user?.userId; - - console.log('User:', user?.email); - console.log('User bucket:', user?.bucket); - console.log('File bucket (from debug):', '695cd596fe2b60d4ec9c3f4e'); - console.log('Buckets gleich?', user?.bucket === '695cd596fe2b60d4ec9c3f4e'); - console.log('User (alle Keys):', Object.keys(user || {})); - console.log('bridgeUser:', bridgeUser ? bridgeUser.slice(0, 8) + '…' : '(fehlt)'); - console.log('bridgePass (userId):', bridgePass ? '***' : '(fehlt)'); - console.log(''); - - if (!bridgeUser || !bridgePass) { - console.error('Bridge-Credentials fehlen (bridgeUser oder userId)'); - process.exit(1); - } - - // Aus debug-files: bucket=695cd596fe2b60d4ec9c3f4e, fileId=69a2bf36fe5cd3b21d81deb7 - const bucketId = '695cd596fe2b60d4ec9c3f4e'; - const fileId = '69a2bf36fe5cd3b21d81deb7'; - - const password = await sha256Hex(bridgePass); - const auth = Buffer.from(`${bridgeUser}:${password}`).toString('base64'); - - const url = `${BRIDGE_URL}/buckets/${bucketId}/files/${fileId}/info`; - console.log('Request:', url); - console.log(''); - - // Test 1: Full SDK-Headers (Basic Auth + x-api-version + internxt-*) - console.log('--- Test 1: Full SDK-Headers ---'); - const sdkHeaders = { - Authorization: `Basic ${auth}`, - 'x-api-version': '2', - 'content-type': 'application/json; charset=utf-8', - 'internxt-version': '1.0', - 'internxt-client': 'drive-web', - }; - const res = await fetch(url, { headers: sdkHeaders }); - - console.log('Status:', res.status, res.statusText); - let text = await res.text(); - if (text) { - try { - console.log('Body:', JSON.stringify(JSON.parse(text), null, 2)); - } catch { - console.log('Body:', text.slice(0, 500)); - } - } - - // Test 2: x-token (wie drive-web manchmal) - if (res.status !== 200) { - console.log(''); - console.log('--- Test 2: x-token Header ---'); - const res2 = await fetch(url, { - headers: { 'x-token': token }, - }); - console.log('Status:', res2.status, res2.statusText); - text = await res2.text(); - if (text) { - try { - console.log('Body:', JSON.stringify(JSON.parse(text), null, 2)); - } catch { - console.log('Body:', text.slice(0, 500)); - } - } - } -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/src/debug-files.js b/src/debug-files.js deleted file mode 100644 index b101fe9..0000000 --- a/src/debug-files.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Debug: Zeigt die Struktur der Dateien aus der Drive API - * Hilft beim Debuggen von Bridge 400 (bucket/fileId Format) - * - * Aufruf: node src/debug-files.js [Pfad] - * Beispiel: node src/debug-files.js / - */ - -import 'dotenv/config'; -import { Storage, Users } from '@internxt/sdk/dist/drive/index.js'; -import { resolveFolder } from './path-resolver.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('INXT_TOKEN fehlt'); - process.exit(1); -} - -const appDetails = { clientName: 'drive-web', clientVersion: '1.0' }; -const apiSecurity = { - token, - unauthorizedCallback: () => { - throw new Error('Token ungültig'); - }, -}; - -async function main() { - const path = process.argv[2] || '/'; - const storage = Storage.client(DRIVE_API_URL, appDetails, apiSecurity); - const users = Users.client(DRIVE_API_URL, appDetails, apiSecurity); - - const refresh = await users.refreshUser(); - const user = refresh?.user ?? refresh; - const rootUuid = user?.rootFolderUuid || user?.rootFolderId; - - if (!rootUuid) { - console.error('Root-Ordner nicht gefunden'); - process.exit(1); - } - - const folder = await resolveFolder(storage, rootUuid, path); - if (!folder) { - console.error('Pfad nicht gefunden:', path); - process.exit(1); - } - - const [contentPromise] = storage.getFolderContentByUuid({ folderUuid: folder.uuid }); - const content = await contentPromise; - const files = content?.files || []; - - console.log('=== Dateien in', path, '(rohe API-Antwort) ===\n'); - for (const f of files) { - console.log('Name:', f.plain_name || f.name || f.plainName); - console.log(' uuid:', f.uuid); - console.log(' bucket:', f.bucket, '(Typ:', typeof f.bucket, ')'); - console.log(' bucket_id:', f.bucket_id); - console.log(' fileId:', f.fileId, '(Typ:', typeof f.fileId, ', Länge:', f.fileId?.length, ')'); - console.log(' file_id:', f.file_id); - console.log(' networkFileId:', f.networkFileId); - console.log(' size:', f.size); - console.log(''); - } -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/src/server.js b/src/server.js index a93e84b..951cca9 100644 --- a/src/server.js +++ b/src/server.js @@ -9,6 +9,7 @@ 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 { getPlainName } from './name-decrypt.js'; import { downloadFileStream } from './download.js'; import { uploadFileBuffer } from './upload.js'; @@ -23,6 +24,49 @@ if (!token) { const app = express(); +// WebDAV-Credentials: Wenn gesetzt, werden Client-Credentials dagegen geprüft. +// Sonst akzeptiert der Server beliebige Basic-Auth-Credentials (für Duplicati etc.). +const webdavUser = process.env.WEBDAV_USER; +const webdavPass = process.env.WEBDAV_PASS; +const authStrict = webdavUser != null && webdavPass != null; + +/** + * Basic-Auth-Middleware: Akzeptiert beliebige Credentials oder prüft gegen WEBDAV_USER/WEBDAV_PASS. + */ +function basicAuth(req, res, next) { + if (req.method === 'OPTIONS') return next(); + + const auth = req.headers?.authorization; + if (!auth || !auth.startsWith('Basic ')) { + res.set('WWW-Authenticate', 'Basic realm="Internxt WebDAV"'); + res.status(401).send('Authentifizierung erforderlich'); + return; + } + + let user, pass; + try { + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); + const colon = decoded.indexOf(':'); + user = colon >= 0 ? decoded.slice(0, colon) : decoded; + pass = colon >= 0 ? decoded.slice(colon + 1) : ''; + } catch (_) { + res.set('WWW-Authenticate', 'Basic realm="Internxt WebDAV"'); + res.status(401).send('Ungültige Credentials'); + return; + } + + if (authStrict && (user !== webdavUser || pass !== webdavPass)) { + res.set('WWW-Authenticate', 'Basic realm="Internxt WebDAV"'); + res.status(401).send('Ungültige Credentials'); + return; + } + + req.webdavUser = user; + next(); +} + +app.use(basicAuth); + // 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' })); @@ -243,7 +287,11 @@ async function handleMkcol(req, res) { const existing = await resolveResource(storage, rootUuid, path); if (existing) { - res.status(405).send('Ressource existiert bereits'); + if (existing.type === 'folder') { + res.status(201).send(); + return; + } + res.status(405).send('Ressource existiert bereits (kein Ordner)'); return; } @@ -550,6 +598,9 @@ async function handlePut(req, res) { } } + // Leere Dateien (z.B. Duplicati duplicati-access-privileges-test.tmp): Bridge braucht mind. 1 Byte + const uploadBuffer = buffer.length > 0 ? buffer : Buffer.from([0]); + const refresh = await refreshUser(token); const user = refresh.user; const bridgeUser = user?.bridgeUser || user?.email; @@ -567,21 +618,49 @@ async function handlePut(req, res) { bridgeUser, bridgePass, mnemonic, - buffer, + buffer: uploadBuffer, }); 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, - }); + + const doCreate = async () => { + await storage.createFileEntryByUuid({ + fileId, + type: type || 'bin', + size: buffer.length, + plainName, + bucket: bucketId, + folderUuid: parent.uuid, + encryptVersion: '03-aes', + modificationTime: date, + date, + }); + }; + + try { + await doCreate(); + } catch (createErr) { + // "File already exists" – Datei per Namen löschen und erneut versuchen + if (createErr?.message?.toLowerCase().includes('already exists')) { + const [contentPromise] = storage.getFolderContentByUuid({ folderUuid: parent.uuid }); + const content = await contentPromise; + const fullName = type ? `${plainName}.${type}` : plainName; + const dup = (content?.files || []).find((f) => { + const apiPlain = f.plain_name ?? f.plainName ?? getPlainName(f.name, null, null, f.folder_id ?? f.folderId); + const apiType = f.type ?? ''; + const apiFull = apiType ? `${apiPlain}.${apiType}` : apiPlain; + return apiFull.toLowerCase() === fullName.toLowerCase(); + }); + if (dup) { + await storage.deleteFileByUuid(dup.uuid); + await doCreate(); + } else { + throw createErr; + } + } else { + throw createErr; + } + } res.status(201).send(); } catch (err) { diff --git a/src/upload.js b/src/upload.js index 9e9f2c3..14e00ac 100644 --- a/src/upload.js +++ b/src/upload.js @@ -57,10 +57,6 @@ export async function uploadFileBuffer(params) { const { bucketId, bridgeUser, bridgePass, mnemonic, buffer, signal } = params; const fileSize = buffer.length; - if (fileSize === 0) { - throw new Error('Leere Datei kann nicht hochgeladen werden'); - } - if (!validateMnemonic(mnemonic)) { throw new Error('Ungültiges Mnemonic'); }