Namensentschlüsselung, Phase 4 PUT, Bridge-Fix, Debug-Tools

- name-decrypt.js: AES-Entschlüsselung für Datei-/Ordnernamen (CRYPTO_SECRET2)
- path-resolver.js: getPlainName für alle Pfad-Operationen
- upload.js: PUT mit Verschlüsselung, Bridge API v2
- download.js: Bridge 400-Fix (x-api-version, Header)
- debug-name-decrypt.js: Test-Skript für Namensentschlüsselung
- docs: CRYPTO_SECRET/CRYPTO_SECRET2 dokumentiert

Made-with: Cursor
This commit is contained in:
2026-02-28 11:49:26 +01:00
parent 43b814d984
commit 406e31f338
13 changed files with 932 additions and 83 deletions

View File

@@ -1,9 +1,13 @@
# Internxt API (Production - aus internxt/cli .env.template) # Internxt API (Production - aus internxt/cli .env.template)
DRIVE_API_URL=https://gateway.internxt.com/drive 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 # Crypto secret - CLI: 6KYQBP847D4ATSFA
# drive-web nutzt REACT_APP_CRYPTO_SECRET (evtl. anderer Wert - aus drive.internxt.com JS extrahieren) # drive-web nutzt REACT_APP_CRYPTO_SECRET (evtl. anderer Wert - aus drive.internxt.com JS extrahieren)
CRYPTO_SECRET=6KYQBP847D4ATSFA 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) # DEBUG=1 # Salt-Decryption testen (ob CRYPTO_SECRET stimmt)

View File

@@ -51,6 +51,9 @@ console.log('Mnemonic:', localStorage.getItem('xMnemonic') || '(nicht gefunden)'
``` ```
INXT_TOKEN=eyJhbGciOiJIUzI1NiIs... INXT_TOKEN=eyJhbGciOiJIUzI1NiIs...
INXT_MNEMONIC=word1 word2 word3 ... 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 ## WebDAV-Server starten
@@ -59,10 +62,28 @@ INXT_MNEMONIC=word1 word2 word3 ...
npm start 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 14 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 ## 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. - **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. - **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. - **Nur für Sie**: Die Tokens sind an Ihre Session gebunden. Für andere Nutzer funktioniert dieser Ansatz nicht.

View File

@@ -52,12 +52,12 @@ Die drive-web nutzt `Network.client` (Bridge) und `NetworkFacade` für Up-/Downl
1. **Phase 1:** PROPFIND (Verzeichnis auflisten) ✅ implementiert 1. **Phase 1:** PROPFIND (Verzeichnis auflisten) ✅ implementiert
2. **Phase 2:** MKCOL, DELETE, MOVE ✅ implementiert 2. **Phase 2:** MKCOL, DELETE, MOVE ✅ implementiert
3. **Phase 3:** GET (Download) Bridge + Entschlüsselung 3. **Phase 3:** GET (Download) Bridge + Entschlüsselung ✅ implementiert
4. **Phase 4:** PUT (Upload) Verschlüsselung + Bridge 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 ## Token vs. Bridge-Credentials

68
package-lock.json generated
View File

@@ -8,25 +8,15 @@
"name": "internxt-webdav", "name": "internxt-webdav",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@dashlane/pqc-kem-kyber512-node": "^1.0.0",
"@internxt/lib": "^1.4.1", "@internxt/lib": "^1.4.1",
"@internxt/sdk": "^1.13.2", "@internxt/sdk": "^1.13.2",
"bip39": "^3.1.0",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.18.0", "express": "^4.18.0",
"openpgp": "^5.11.3",
"webdav-server": "^2.6.0" "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": { "node_modules/@internxt/lib": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/@internxt/lib/-/lib-1.4.1.tgz", "resolved": "https://registry.npmjs.org/@internxt/lib/-/lib-1.4.1.tgz",
@@ -130,18 +120,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -159,11 +137,26 @@
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
"node_modules/bn.js": { "node_modules/bip39": {
"version": "4.12.3", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==",
"license": "MIT" "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": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
@@ -833,12 +826,6 @@
"node": ">= 0.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": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -878,19 +865,6 @@
"node": ">= 0.8" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",

View File

@@ -6,11 +6,13 @@
"scripts": { "scripts": {
"auth-test": "node src/auth-poc.js", "auth-test": "node src/auth-poc.js",
"token-test": "node src/token-test.js", "token-test": "node src/token-test.js",
"debug-names": "node src/debug-name-decrypt.js",
"start": "node src/server.js" "start": "node src/server.js"
}, },
"dependencies": { "dependencies": {
"@internxt/lib": "^1.4.1", "@internxt/lib": "^1.4.1",
"@internxt/sdk": "^1.13.2", "@internxt/sdk": "^1.13.2",
"bip39": "^3.1.0",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.18.0", "express": "^4.18.0",

106
src/bridge-test.js Normal file
View File

@@ -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);
});

70
src/debug-files.js Normal file
View File

@@ -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);
});

91
src/debug-name-decrypt.js Normal file
View File

@@ -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);
});

133
src/download.js Normal file
View File

@@ -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);
}

37
src/name-decrypt.js Normal file
View File

@@ -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;
}
}

View File

@@ -1,7 +1,16 @@
/** /**
* WebDAV-Pfad ↔ Internxt-UUID Auflösung * 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 /). * Normalisiert WebDAV-Pfad zu Segmenten (ohne führendes /).
* "/" -> [], "/folder1" -> ["folder1"], "/folder1/sub" -> ["folder1","sub"] * "/" -> [], "/folder1" -> ["folder1"], "/folder1/sub" -> ["folder1","sub"]
@@ -32,9 +41,10 @@ export async function resolveFolder(storage, rootFolderUuid, path) {
for (const segment of segments) { for (const segment of segments) {
const [contentPromise] = storage.getFolderContentByUuid({ folderUuid: currentUuid }); const [contentPromise] = storage.getFolderContentByUuid({ folderUuid: currentUuid });
const content = await contentPromise; const content = await contentPromise;
const child = content?.children?.find( const child = content?.children?.find((c) => {
(c) => (c.plain_name || c.name || '').toLowerCase() === segment.toLowerCase() 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; if (!child) return null;
currentUuid = child.uuid; currentUuid = child.uuid;
} }
@@ -59,11 +69,11 @@ export async function listFolder(storage, rootFolderUuid, path) {
const folders = (content?.children || []).map((c) => ({ const folders = (content?.children || []).map((c) => ({
uuid: c.uuid, 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) => ({ const files = (content?.files || []).map((f) => ({
uuid: f.uuid, 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, size: f.size || 0,
updatedAt: f.updatedAt || f.modificationTime || f.creationTime, 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 [contentPromise] = storage.getFolderContentByUuid({ folderUuid: parent.uuid });
const content = await contentPromise; const content = await contentPromise;
const folder = content?.children?.find( const uuidMatch = childName.match(/^_.([a-f0-9-]{36})$/i);
(c) => (c.plain_name || c.name || '').toLowerCase() === childName.toLowerCase() if (uuidMatch) {
); const file = content?.files?.find((f) => f.uuid === uuidMatch[1]);
if (folder) { if (file) {
return { uuid: folder.uuid, type: 'folder', name: folder.plain_name || folder.name, 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,
};
}
} }
const file = content?.files?.find( const folder = content?.children?.find((c) => {
(f) => (f.plain_name || f.name || f.plainName || '').toLowerCase() === childName.toLowerCase() 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) { 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; return null;

View File

@@ -1,5 +1,5 @@
/** /**
* WebDAV-Server für Internxt Drive (Phase 1+2: PROPFIND, MKCOL, DELETE, MOVE) * WebDAV-Server für Internxt Drive (Phase 14: PROPFIND, MKCOL, DELETE, MOVE, GET, PUT)
* *
* Nutzt Browser-Token (INXT_TOKEN, INXT_MNEMONIC) aus .env. * Nutzt Browser-Token (INXT_TOKEN, INXT_MNEMONIC) aus .env.
* Siehe docs/browser-token-auth.md * Siehe docs/browser-token-auth.md
@@ -9,9 +9,12 @@ import 'dotenv/config';
import express from 'express'; import express from 'express';
import { createClients, refreshUser } from './internxt-client.js'; import { createClients, refreshUser } from './internxt-client.js';
import { pathToSegments, segmentsToPath, listFolder, resolveFolder, resolveResource } from './path-resolver.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 PORT = parseInt(process.env.PORT || '3005', 10);
const token = process.env.INXT_TOKEN; const token = process.env.INXT_TOKEN;
const mnemonic = process.env.INXT_MNEMONIC;
if (!token) { if (!token) {
console.error('Fehler: INXT_TOKEN muss gesetzt sein. Siehe docs/browser-token-auth.md'); console.error('Fehler: INXT_TOKEN muss gesetzt sein. Siehe docs/browser-token-auth.md');
@@ -20,7 +23,8 @@ if (!token) {
const app = express(); 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' })); app.use(express.text({ type: 'application/xml', limit: '1kb' }));
/** /**
@@ -32,12 +36,34 @@ function toLastModified(iso) {
return d.toUTCString(); 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 * Escaped XML-Text
*/ */
function escapeXml(s) { function escapeXml(s) {
if (s == null) return ''; if (s == null) return '';
return String(s) return sanitizeForPath(String(s))
.replace(/&/g, '&') .replace(/&/g, '&')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
@@ -51,7 +77,16 @@ function escapeXml(s) {
function buildPropfindResponse(baseUrl, items) { function buildPropfindResponse(baseUrl, items) {
const ns = 'DAV:'; const ns = 'DAV:';
const responses = items.map((item) => { 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 lastMod = toLastModified(item.updatedAt);
const resourcetype = item.isCollection const resourcetype = item.isCollection
? `<D:resourcetype><D:collection/></D:resourcetype>` ? `<D:resourcetype><D:collection/></D:resourcetype>`
@@ -83,12 +118,10 @@ function buildPropfindResponse(baseUrl, items) {
*/ */
async function handlePropfind(req, res) { async function handlePropfind(req, res) {
const depth = req.headers['depth'] || '1'; const depth = req.headers['depth'] || '1';
let path = req.url || '/'; let path = getPathFromRequest(req);
try {
path = decodeURIComponent(path);
} catch (_) {}
if (!path.startsWith('/')) path = '/' + path; if (!path.startsWith('/')) path = '/' + path;
if (path !== '/' && path.endsWith('/')) path = path.slice(0, -1); if (path !== '/' && path.endsWith('/')) path = path.slice(0, -1);
path = sanitizeForPath(path);
const baseUrl = `${req.protocol}://${req.get('host')}`; const baseUrl = `${req.protocol}://${req.get('host')}`;
@@ -124,28 +157,34 @@ async function handlePropfind(req, res) {
// Kinder bei depth 1 // Kinder bei depth 1
if (depth !== '0') { if (depth !== '0') {
for (const f of listing.folders) { 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({ items.push({
path: childPath, path: childPath,
name: f.name, name: safeName,
isCollection: true, isCollection: true,
updatedAt: null, updatedAt: null,
size: null, size: null,
}); });
} }
for (const f of listing.files) { 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({ items.push({
path: childPath, path: childPath,
name: f.name, name: useUuidPath ? f.uuid : rawName,
isCollection: false, isCollection: false,
updatedAt: f.updatedAt, updatedAt: f.updatedAt,
size: f.size, 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.set('Content-Type', 'application/xml; charset="utf-8"');
res.status(207).send(xml); res.status(207).send(xml);
} catch (err) { } 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 // WebDAV Endpoints
app.options('*', (req, res) => { app.options('*', (req, res) => {
res.set('DAV', '1, 2'); res.set('DAV', '1, 2');
@@ -362,12 +607,25 @@ app.use((req, res, next) => {
handlePropfind(req, res); handlePropfind(req, res);
return; return;
} }
if (req.method === 'GET' || req.method === 'HEAD') { if (req.method === 'GET') {
res.status(501).send('GET (Download) noch nicht implementiert Phase 3'); 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; return;
} }
if (req.method === 'PUT') { 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; return;
} }
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
@@ -396,6 +654,6 @@ app.use((req, res, next) => {
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Internxt WebDAV Server http://127.0.0.1:${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 14: PROPFIND, MKCOL, DELETE, MOVE, GET, PUT aktiv.');
console.log('Verwendung: z.B. Windows Explorer → Netzlaufwerk verbinden'); console.log('Verwendung: z.B. Windows Explorer → Netzlaufwerk verbinden');
}); });

112
src/upload.js Normal file
View File

@@ -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<string>} 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;
}