diff --git a/.env.example b/.env.example
index 910899b..dc4ea0f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,9 +1,13 @@
# Internxt API (Production - aus internxt/cli .env.template)
DRIVE_API_URL=https://gateway.internxt.com/drive
+# Bridge/Network für Datei-Up/Download (optional, Default: gateway.internxt.com/network)
+# BRIDGE_URL=https://gateway.internxt.com/network
# Crypto secret - CLI: 6KYQBP847D4ATSFA
# drive-web nutzt REACT_APP_CRYPTO_SECRET (evtl. anderer Wert - aus drive.internxt.com JS extrahieren)
CRYPTO_SECRET=6KYQBP847D4ATSFA
+# Für Namensentschlüsselung (CRYPTO_SECRET2). Falls nicht gesetzt, wird CRYPTO_SECRET verwendet.
+# CRYPTO_SECRET2=6KYQBP847D4ATSFA
# DEBUG=1 # Salt-Decryption testen (ob CRYPTO_SECRET stimmt)
diff --git a/docs/browser-token-auth.md b/docs/browser-token-auth.md
index b3bf2f0..b5d46a1 100644
--- a/docs/browser-token-auth.md
+++ b/docs/browser-token-auth.md
@@ -51,6 +51,9 @@ 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
```
## WebDAV-Server starten
@@ -59,10 +62,28 @@ INXT_MNEMONIC=word1 word2 word3 ...
npm start
```
-Server läuft auf `http://127.0.0.1:3005`. Phase 1+2 aktiv: PROPFIND, MKCOL (Ordner anlegen), DELETE, MOVE. Windows Explorer: Netzlaufwerk verbinden → `http://127.0.0.1:3005`.
+Server läuft auf `http://127.0.0.1:3005`. Phase 1–4 aktiv: PROPFIND, MKCOL, DELETE, MOVE, GET, PUT. Für GET und PUT wird INXT_MNEMONIC benötigt.
+
+### PowerShell Copy-Item: „Null character in path“
+
+Windows/.NET fügt bei WebDAV-Pfaden manchmal Null-Bytes ein. **Workaround:**
+
+```powershell
+# Variante 1: Direkt per HTTP (umgeht WebDAV-Bugs, UUID aus dir i: übernehmen)
+Invoke-WebRequest -Uri "http://127.0.0.1:3005/_.69942103-e16f-4714-89bb-9f9f7d3b1bd5" -OutFile test.md
+
+# Upload per PUT (PowerShell)
+Invoke-WebRequest -Uri "http://127.0.0.1:3005/meine-datei.txt" -Method PUT -Body "Inhalt" -ContentType "application/octet-stream"
+
+# Variante 2: Robocopy (kopiert alle Dateien aus Root)
+robocopy "i:\" "." /NFL /NDL
+
+# Variante 3: Explorer – Datei per Drag & Drop kopieren
+``` Windows Explorer: Netzlaufwerk verbinden → `http://127.0.0.1:3005`.
## Hinweise
+- **Bridge-API**: Der Download nutzt die Internxt Bridge mit `x-api-version: 2` und den Headern `internxt-version`/`internxt-client`. Ohne diese liefert die Bridge 400.
- **Token-Ablauf**: Tokens laufen nach einiger Zeit ab (typisch Stunden). Bei 401-Fehlern erneut einloggen und Token aktualisieren.
- **Sicherheit**: Mnemonic und Token sind hochsensibel. Nicht in Git committen, `.env` in `.gitignore` belassen.
- **Nur für Sie**: Die Tokens sind an Ihre Session gebunden. Für andere Nutzer funktioniert dieser Ansatz nicht.
diff --git a/docs/webdav-architektur.md b/docs/webdav-architektur.md
index 26a6fcf..0c97134 100644
--- a/docs/webdav-architektur.md
+++ b/docs/webdav-architektur.md
@@ -52,12 +52,12 @@ Die drive-web nutzt `Network.client` (Bridge) und `NetworkFacade` für Up-/Downl
1. **Phase 1:** PROPFIND (Verzeichnis auflisten) – ✅ implementiert
2. **Phase 2:** MKCOL, DELETE, MOVE – ✅ implementiert
-3. **Phase 3:** GET (Download) – Bridge + Entschlüsselung
-4. **Phase 4:** PUT (Upload) – Verschlüsselung + Bridge
+3. **Phase 3:** GET (Download) – Bridge + Entschlüsselung – ✅ implementiert
+4. **Phase 4:** PUT (Upload) – Verschlüsselung + Bridge – ✅ implementiert
-### Hinweis: Ordnernamen
+### Namensentschlüsselung
-Internxt nutzt Zero-Knowledge-Verschlüsselung. Die API liefert verschlüsselte Namen (`name`). Die Pfadauflösung funktioniert, weil WebDAV-URLs diese Namen enthalten. Eine spätere Phase kann Namensentschlüsselung mit Mnemonic ergänzen (drive-web Crypto-Logik).
+Internxt nutzt Zero-Knowledge-Verschlüsselung. Die API liefert verschlüsselte Namen (`name`). Wenn `plain_name` fehlt, kann der Server mit `CRYPTO_SECRET2` (oder `CRYPTO_SECRET`) Namen entschlüsseln – analog zur drive-web `aes.decrypt`-Logik mit `secret2-parentId`/`secret2-folderId`. Ohne gesetztes Secret werden die rohen (verschlüsselten) Namen verwendet.
## Token vs. Bridge-Credentials
diff --git a/package-lock.json b/package-lock.json
index 434fd61..d58b4bd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,25 +8,15 @@
"name": "internxt-webdav",
"version": "0.1.0",
"dependencies": {
- "@dashlane/pqc-kem-kyber512-node": "^1.0.0",
"@internxt/lib": "^1.4.1",
"@internxt/sdk": "^1.13.2",
+ "bip39": "^3.1.0",
"crypto-js": "^4.1.1",
"dotenv": "^16.0.0",
"express": "^4.18.0",
- "openpgp": "^5.11.3",
"webdav-server": "^2.6.0"
}
},
- "node_modules/@dashlane/pqc-kem-kyber512-node": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@dashlane/pqc-kem-kyber512-node/-/pqc-kem-kyber512-node-1.0.0.tgz",
- "integrity": "sha512-gVzQwP/1OqKLyYZ/oRI9uECSnYIcLUcZbnAA34Q2l8X1eXq5JWf304tDp1UTdYdJ+ZE58SmQ68VCa/WvpCviGw==",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=16.0.0"
- }
- },
"node_modules/@internxt/lib": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@internxt/lib/-/lib-1.4.1.tgz",
@@ -130,18 +120,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
- "node_modules/asn1.js": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
- "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
- "license": "MIT",
- "dependencies": {
- "bn.js": "^4.0.0",
- "inherits": "^2.0.1",
- "minimalistic-assert": "^1.0.0",
- "safer-buffer": "^2.1.0"
- }
- },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -159,11 +137,26 @@
"proxy-from-env": "^1.1.0"
}
},
- "node_modules/bn.js": {
- "version": "4.12.3",
- "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
- "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
- "license": "MIT"
+ "node_modules/bip39": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz",
+ "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==",
+ "license": "ISC",
+ "dependencies": {
+ "@noble/hashes": "^1.2.0"
+ }
+ },
+ "node_modules/bip39/node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
},
"node_modules/body-parser": {
"version": "1.20.4",
@@ -833,12 +826,6 @@
"node": ">= 0.6"
}
},
- "node_modules/minimalistic-assert": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
- "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
- "license": "ISC"
- },
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -878,19 +865,6 @@
"node": ">= 0.8"
}
},
- "node_modules/openpgp": {
- "version": "5.11.3",
- "resolved": "https://registry.npmjs.org/openpgp/-/openpgp-5.11.3.tgz",
- "integrity": "sha512-jXOPfIteBUQ2zSmRG4+Y6PNntIIDEAvoM/lOYCnvpXAByJEruzrHQZWE/0CGOKHbubwUuty2HoPHsqBzyKHOpA==",
- "deprecated": "This version is deprecated and will no longer receive security patches. Please refer to https://github.com/openpgpjs/openpgpjs/wiki/Updating-from-previous-versions for details on how to upgrade to a newer supported version.",
- "license": "LGPL-3.0+",
- "dependencies": {
- "asn1.js": "^5.0.0"
- },
- "engines": {
- "node": ">= 8.0.0"
- }
- },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
diff --git a/package.json b/package.json
index 8a84a72..5347ae4 100644
--- a/package.json
+++ b/package.json
@@ -6,11 +6,13 @@
"scripts": {
"auth-test": "node src/auth-poc.js",
"token-test": "node src/token-test.js",
+ "debug-names": "node src/debug-name-decrypt.js",
"start": "node src/server.js"
},
"dependencies": {
"@internxt/lib": "^1.4.1",
"@internxt/sdk": "^1.13.2",
+ "bip39": "^3.1.0",
"crypto-js": "^4.1.1",
"dotenv": "^16.0.0",
"express": "^4.18.0",
diff --git a/src/bridge-test.js b/src/bridge-test.js
new file mode 100644
index 0000000..0783093
--- /dev/null
+++ b/src/bridge-test.js
@@ -0,0 +1,106 @@
+/**
+ * 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
new file mode 100644
index 0000000..b101fe9
--- /dev/null
+++ b/src/debug-files.js
@@ -0,0 +1,70 @@
+/**
+ * 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/debug-name-decrypt.js b/src/debug-name-decrypt.js
new file mode 100644
index 0000000..f18d4b2
--- /dev/null
+++ b/src/debug-name-decrypt.js
@@ -0,0 +1,91 @@
+/**
+ * Debug: Testet Namensentschlüsselung
+ * Aufruf: node src/debug-name-decrypt.js [Pfad]
+ *
+ * Zeigt für jede Datei/Ordner: verschlüsselter Name → entschlüsselter Name
+ * Hilft zu prüfen, ob CRYPTO_SECRET2 korrekt ist.
+ */
+
+import 'dotenv/config';
+import { Storage, Users } from '@internxt/sdk/dist/drive/index.js';
+import { getPlainName } from './name-decrypt.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 secret = process.env.CRYPTO_SECRET2 || process.env.CRYPTO_SECRET;
+if (!secret) {
+ console.error('CRYPTO_SECRET oder CRYPTO_SECRET2 fehlt in .env');
+ 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 folders = content?.children || [];
+ const files = content?.files || [];
+
+ console.log('=== Namensentschlüsselung in', path, '===');
+ console.log('CRYPTO_SECRET2/CRYPTO_SECRET:', secret ? secret.substring(0, 4) + '***' : '(nicht gesetzt)');
+ console.log('');
+
+ for (const c of folders) {
+ const plain = getPlainName(c.name, c.plain_name ?? c.plainName, c.parent_id ?? c.parentId, null);
+ const ok = plain !== c.name && plain.length > 0 && !/^[A-Za-z0-9+/=]{20,}$/.test(plain);
+ console.log('Ordner:', ok ? '✓' : '✗', plain);
+ console.log(' verschlüsselt:', c.name?.substring(0, 50) + '...');
+ console.log(' parent_id:', c.parent_id ?? c.parentId);
+ console.log('');
+ }
+
+ for (const f of files) {
+ const plain = getPlainName(f.name, f.plain_name ?? f.plainName, null, f.folder_id ?? f.folderId);
+ const ok = plain !== f.name && plain.length > 0 && !/^[A-Za-z0-9+/=]{20,}$/.test(plain);
+ console.log('Datei:', ok ? '✓' : '✗', plain);
+ console.log(' verschlüsselt:', f.name?.substring(0, 50) + '...');
+ console.log(' folder_id:', f.folder_id ?? f.folderId);
+ console.log('');
+ }
+
+ if (folders.length === 0 && files.length === 0) {
+ console.log('(Leerer Ordner)');
+ }
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/src/download.js b/src/download.js
new file mode 100644
index 0000000..8d4024d
--- /dev/null
+++ b/src/download.js
@@ -0,0 +1,133 @@
+/**
+ * Internxt Datei-Download – Bridge API + Entschlüsselung
+ */
+
+import crypto from 'crypto';
+import { mnemonicToSeed } from 'bip39';
+
+const BRIDGE_URL = process.env.BRIDGE_URL || process.env.STORJ_BRIDGE || 'https://gateway.internxt.com/network';
+
+async function sha256Hex(data) {
+ return crypto.createHash('sha256').update(data).digest('hex');
+}
+
+async function sha512Combined(key, data) {
+ return crypto.createHash('sha512').update(key).update(data).digest('hex');
+}
+
+async function getFileDeterministicKey(key, data) {
+ const hashHex = await sha512Combined(key, data);
+ return Buffer.from(hashHex, 'hex');
+}
+
+async function generateFileBucketKey(mnemonic, bucketId) {
+ const seed = await mnemonicToSeed(mnemonic);
+ return getFileDeterministicKey(seed, Buffer.from(bucketId, 'hex'));
+}
+
+export async function generateFileKey(mnemonic, bucketId, index) {
+ const bucketKey = await generateFileBucketKey(mnemonic, bucketId);
+ return (await getFileDeterministicKey(bucketKey.subarray(0, 32), index)).subarray(0, 32);
+}
+
+async function getAuth(bridgeUser, bridgePass) {
+ const password = await sha256Hex(bridgePass);
+ const encoded = Buffer.from(`${bridgeUser}:${password}`).toString('base64');
+ return `Basic ${encoded}`;
+}
+
+const BRIDGE_HEADERS = {
+ 'x-api-version': '2',
+ 'content-type': 'application/json; charset=utf-8',
+ 'internxt-version': '1.0',
+ 'internxt-client': 'drive-web',
+};
+
+async function getFileInfo(bucketId, fileId, authHeader, opts = {}) {
+ const url = `${BRIDGE_URL}/buckets/${bucketId}/files/${fileId}/info`;
+ const res = await fetch(url, {
+ headers: { Authorization: authHeader, ...BRIDGE_HEADERS },
+ ...opts,
+ });
+ if (!res.ok) {
+ let detail = '';
+ try {
+ const text = await res.text();
+ if (text) detail = ` – ${text.slice(0, 200)}`;
+ } catch (_) {}
+ throw new Error(`Bridge file info: ${res.status}${detail}`);
+ }
+ return res.json();
+}
+
+/** Holt Download-URLs: nutzt shards aus fileInfo (API v2) oder fallback auf mirrors-Endpoint */
+async function getDownloadUrls(bucketId, fileId, authHeader, fileInfo, opts = {}) {
+ if (fileInfo?.shards?.length) {
+ return fileInfo.shards.sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
+ }
+ const mirrors = [];
+ let skip = 0;
+ const limit = 3;
+ while (true) {
+ const res = await fetch(
+ `${BRIDGE_URL}/buckets/${bucketId}/files/${fileId}?limit=${limit}&skip=${skip}`,
+ { headers: { Authorization: authHeader, ...BRIDGE_HEADERS }, ...opts }
+ );
+ if (!res.ok) throw new Error(`Bridge mirrors: ${res.status}`);
+ const results = await res.json();
+ const valid = results
+ .filter((m) => m.farmer?.nodeID && m.farmer?.port && m.farmer?.address)
+ .filter((m) => !m.parity)
+ .sort((a, b) => a.index - b.index);
+ valid.forEach((r) => mirrors.push(r));
+ if (results.length === 0) break;
+ skip += limit;
+ }
+ return mirrors;
+}
+
+/**
+ * Lädt eine Datei herunter und gibt einen ReadableStream zurück.
+ * @param {{ bucketId: string, fileId: string, bridgeUser: string, bridgePass: string, mnemonic: string, signal?: AbortSignal }} params
+ */
+export async function downloadFileStream(params) {
+ const { bucketId, fileId, bridgeUser, bridgePass, mnemonic, signal } = params;
+ const fetchOpts = signal ? { signal } : {};
+
+ const authHeader = await getAuth(bridgeUser, bridgePass);
+ const fileMeta = await getFileInfo(bucketId, fileId, authHeader, fetchOpts);
+ const downloadItems = await getDownloadUrls(bucketId, fileId, authHeader, fileMeta, fetchOpts);
+
+ const index = Buffer.from(fileMeta.index, 'hex');
+ const iv = index.slice(0, 16);
+ const key = await generateFileKey(mnemonic, bucketId, index);
+ const decipher = crypto.createDecipheriv('aes-256-ctr', key, iv);
+
+ const sortedItems = downloadItems.sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
+ const { Readable } = await import('stream');
+
+ const combined = new Readable({
+ async read() {
+ if (this._fetched) return;
+ this._fetched = true;
+ try {
+ for (const item of sortedItems) {
+ const res = await fetch(item.url, fetchOpts);
+ if (!res.ok) throw new Error(`Download shard: ${res.status}`);
+ if (!res.body) throw new Error('No content received');
+ const reader = res.body.getReader();
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ this.push(value);
+ }
+ }
+ this.push(null);
+ } catch (err) {
+ this.destroy(err);
+ }
+ },
+ });
+
+ return combined.pipe(decipher);
+}
diff --git a/src/name-decrypt.js b/src/name-decrypt.js
new file mode 100644
index 0000000..c442243
--- /dev/null
+++ b/src/name-decrypt.js
@@ -0,0 +1,37 @@
+/**
+ * Entschlüsselung von Datei- und Ordnernamen (Zero-Knowledge)
+ * Nutzt @internxt/lib aes.decrypt mit CRYPTO_SECRET2 + parentId/folderId
+ * Siehe drive-web: app/crypto/services/utils.ts getItemPlainName
+ */
+
+import { aes } from '@internxt/lib';
+
+const CRYPTO_SECRET2 = process.env.CRYPTO_SECRET2 || process.env.CRYPTO_SECRET || '';
+
+/**
+ * Entschlüsselt einen Namen, falls nötig.
+ * @param {string} encryptedName - Verschlüsselter Name (base64)
+ * @param {string} plainName - Bereits entschlüsselter Name (von API)
+ * @param {number} [parentId] - Für Ordner: parent_id
+ * @param {number} [folderId] - Für Dateien: folder_id
+ * @returns {string} - Plain-Name
+ */
+export function getPlainName(encryptedName, plainName, parentId, folderId) {
+ if (plainName != null && String(plainName).trim().length > 0) {
+ return String(plainName).trim();
+ }
+ if (!encryptedName) return 'Unbenannt';
+ if (!CRYPTO_SECRET2) return encryptedName;
+ try {
+ const key = parentId != null
+ ? `${CRYPTO_SECRET2}-${parentId}`
+ : folderId != null
+ ? `${CRYPTO_SECRET2}-${folderId}`
+ : null;
+ if (!key) return encryptedName;
+ const decrypted = aes.decrypt(encryptedName, key);
+ return decrypted && String(decrypted).trim() ? String(decrypted).trim() : encryptedName;
+ } catch (_) {
+ return encryptedName;
+ }
+}
diff --git a/src/path-resolver.js b/src/path-resolver.js
index 17009af..d10b198 100644
--- a/src/path-resolver.js
+++ b/src/path-resolver.js
@@ -1,7 +1,16 @@
/**
* WebDAV-Pfad ↔ Internxt-UUID Auflösung
+ * Mit optionaler Namensentschlüsselung (CRYPTO_SECRET2)
*/
+import { getPlainName } from './name-decrypt.js';
+
+/** Entfernt Null-Bytes und Steuerzeichen (für Windows-Kompatibilität) */
+function sanitize(s) {
+ if (s == null) return '';
+ return String(s).replace(/\0/g, '').replace(/[\x00-\x1f\x7f]/g, '');
+}
+
/**
* Normalisiert WebDAV-Pfad zu Segmenten (ohne führendes /).
* "/" -> [], "/folder1" -> ["folder1"], "/folder1/sub" -> ["folder1","sub"]
@@ -32,9 +41,10 @@ export async function resolveFolder(storage, rootFolderUuid, path) {
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()
- );
+ const child = content?.children?.find((c) => {
+ const name = getPlainName(c.name, c.plain_name ?? c.plainName, c.parent_id ?? c.parentId, null);
+ return sanitize(name).toLowerCase() === sanitize(segment).toLowerCase();
+ });
if (!child) return null;
currentUuid = child.uuid;
}
@@ -59,11 +69,11 @@ export async function listFolder(storage, rootFolderUuid, path) {
const folders = (content?.children || []).map((c) => ({
uuid: c.uuid,
- name: c.plain_name || c.name || 'Unbenannt',
+ name: getPlainName(c.name, c.plain_name ?? c.plainName, c.parent_id ?? c.parentId, null) || 'Unbenannt',
}));
const files = (content?.files || []).map((f) => ({
uuid: f.uuid,
- name: f.plain_name || f.name || f.plainName || 'Unbenannt',
+ name: getPlainName(f.name, f.plain_name ?? f.plainName, null, f.folder_id ?? f.folderId) || 'Unbenannt',
size: f.size || 0,
updatedAt: f.updatedAt || f.modificationTime || f.creationTime,
}));
@@ -90,18 +100,49 @@ export async function resolveResource(storage, rootFolderUuid, path) {
const [contentPromise] = storage.getFolderContentByUuid({ folderUuid: parent.uuid });
const content = await contentPromise;
- const folder = content?.children?.find(
- (c) => (c.plain_name || c.name || '').toLowerCase() === childName.toLowerCase()
- );
- if (folder) {
- return { uuid: folder.uuid, type: 'folder', name: folder.plain_name || folder.name, parentUuid: parent.uuid };
+ const uuidMatch = childName.match(/^_.([a-f0-9-]{36})$/i);
+ if (uuidMatch) {
+ const file = content?.files?.find((f) => f.uuid === uuidMatch[1]);
+ if (file) {
+ const bucket = file.bucket ?? file.bucket_id;
+ const fileId = file.fileId ?? file.file_id ?? file.networkFileId;
+ const name = getPlainName(file.name, file.plain_name ?? file.plainName, null, file.folder_id ?? file.folderId);
+ return {
+ uuid: file.uuid,
+ type: 'file',
+ name,
+ parentUuid: parent.uuid,
+ bucket,
+ fileId,
+ };
+ }
}
- const file = content?.files?.find(
- (f) => (f.plain_name || f.name || f.plainName || '').toLowerCase() === childName.toLowerCase()
- );
+ const folder = content?.children?.find((c) => {
+ const name = getPlainName(c.name, c.plain_name ?? c.plainName, c.parent_id ?? c.parentId, null);
+ return sanitize(name).toLowerCase() === sanitize(childName).toLowerCase();
+ });
+ if (folder) {
+ const name = getPlainName(folder.name, folder.plain_name ?? folder.plainName, folder.parent_id ?? folder.parentId, null);
+ return { uuid: folder.uuid, type: 'folder', name, parentUuid: parent.uuid };
+ }
+
+ const file = content?.files?.find((f) => {
+ const name = getPlainName(f.name, f.plain_name ?? f.plainName, null, f.folder_id ?? f.folderId);
+ return sanitize(name).toLowerCase() === sanitize(childName).toLowerCase();
+ });
if (file) {
- return { uuid: file.uuid, type: 'file', name: file.plain_name || file.name || file.plainName, parentUuid: parent.uuid };
+ const bucket = file.bucket ?? file.bucket_id;
+ const fileId = file.fileId ?? file.file_id ?? file.networkFileId;
+ const name = getPlainName(file.name, file.plain_name ?? file.plainName, null, file.folder_id ?? file.folderId);
+ return {
+ uuid: file.uuid,
+ type: 'file',
+ name,
+ parentUuid: parent.uuid,
+ bucket,
+ fileId,
+ };
}
return null;
diff --git a/src/server.js b/src/server.js
index 20611fc..a93e84b 100644
--- a/src/server.js
+++ b/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, '>')
@@ -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
? ``
@@ -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');
});
diff --git a/src/upload.js b/src/upload.js
new file mode 100644
index 0000000..9e9f2c3
--- /dev/null
+++ b/src/upload.js
@@ -0,0 +1,112 @@
+/**
+ * Internxt Datei-Upload – Bridge API + Verschlüsselung
+ * Nutzt @internxt/sdk Network + uploadFile
+ */
+
+import crypto from 'crypto';
+import { validateMnemonic } from 'bip39';
+import { Network } from '@internxt/sdk/dist/network/index.js';
+import { uploadFile as sdkUploadFile } from '@internxt/sdk/dist/network/upload.js';
+import { generateFileKey } from './download.js';
+
+const BRIDGE_URL = process.env.BRIDGE_URL || process.env.STORJ_BRIDGE || 'https://gateway.internxt.com/network';
+
+async function sha256Hex(data) {
+ return crypto.createHash('sha256').update(data).digest('hex');
+}
+
+async function ripemd160FromHex(dataHex) {
+ return crypto.createHash('ripemd160').update(Buffer.from(dataHex, 'hex')).digest('hex');
+}
+
+async function getAuth(bridgeUser, bridgePass) {
+ const password = await sha256Hex(bridgePass);
+ return { username: bridgeUser, password };
+}
+
+async function getEncryptedBuffer(plainBuffer, cipher) {
+ const encrypted = Buffer.concat([cipher.update(plainBuffer), cipher.final()]);
+ const sha256HexDigest = crypto.createHash('sha256').update(encrypted).digest('hex');
+ const hash = await ripemd160FromHex(sha256HexDigest);
+ return [encrypted, hash];
+}
+
+async function uploadToUrl(buffer, url, signal) {
+ const res = await fetch(url, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/octet-stream' },
+ body: buffer,
+ signal,
+ });
+ if (!res.ok) throw new Error(`Upload fehlgeschlagen: ${res.status}`);
+}
+
+/**
+ * Lädt eine Datei hoch und gibt die Bridge fileId zurück.
+ * @param {{
+ * bucketId: string,
+ * bridgeUser: string,
+ * bridgePass: string,
+ * mnemonic: string,
+ * buffer: Buffer,
+ * signal?: AbortSignal
+ * }} params
+ * @returns {Promise} fileId
+ */
+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');
+ }
+
+ const auth = await getAuth(bridgeUser, bridgePass);
+ const network = Network.client(BRIDGE_URL, {
+ clientName: 'drive-web',
+ clientVersion: '1.0',
+ }, {
+ bridgeUser: auth.username,
+ userId: auth.password,
+ });
+
+ let encryptedBuffer = null;
+ let contentHash = null;
+
+ const encryptFile = async (_algorithm, key, iv) => {
+ const cipher = crypto.createCipheriv('aes-256-ctr', key, iv);
+ [encryptedBuffer, contentHash] = await getEncryptedBuffer(buffer, cipher);
+ };
+
+ const uploadToBridge = async (url) => {
+ let abortSignal;
+ if (signal) {
+ const ctrl = new AbortController();
+ signal.addEventListener('abort', () => ctrl.abort());
+ abortSignal = ctrl.signal;
+ }
+ await uploadToUrl(encryptedBuffer, url, abortSignal);
+ return contentHash;
+ };
+
+ const fileId = await sdkUploadFile(
+ network,
+ {
+ validateMnemonic,
+ randomBytes: (n) => crypto.randomBytes(n),
+ generateFileKey: (m, b, i) => generateFileKey(m, b, i),
+ algorithm: { type: 'aes-256-ctr', ivSize: 32 },
+ },
+ bucketId,
+ mnemonic,
+ fileSize,
+ encryptFile,
+ uploadToBridge
+ );
+
+ return fileId;
+}