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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
68
package-lock.json
generated
68
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
106
src/bridge-test.js
Normal file
106
src/bridge-test.js
Normal 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
70
src/debug-files.js
Normal 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
91
src/debug-name-decrypt.js
Normal 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
133
src/download.js
Normal 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
37
src/name-decrypt.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
292
src/server.js
292
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, '<')
|
||||
.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
|
||||
? `<D:resourcetype><D:collection/></D:resourcetype>`
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
112
src/upload.js
Normal file
112
src/upload.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user