squidcloudctl 3.0.2 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/squidcloud.js +2 -0
- package/dist/commands/files/download.js +1 -1
- package/dist/lib/crypto/res54.js +94 -0
- package/dist/lib/transfer.js +246 -34
- package/package.json +1 -1
package/dist/bin/squidcloud.js
CHANGED
|
@@ -82,6 +82,8 @@ const OPTIONS = {
|
|
|
82
82
|
'files ls': [['--refresh', 'Bypass the local listing cache']],
|
|
83
83
|
'files up': [['--folder <ref>', 'Target folder id or name'], ['--quiet', 'Suppress progress output']],
|
|
84
84
|
'files dl': [['-o, --out <path>', 'Output path']],
|
|
85
|
+
'storage dl': [['-o, --out <path>', 'Output path']],
|
|
86
|
+
'files list': [['--refresh', 'Bypass listing cache']],
|
|
85
87
|
'storage ls': [['--folder <ref>', 'List inside a folder'], ['--search <q>', 'Filter by name']],
|
|
86
88
|
'share create': [
|
|
87
89
|
['--expires <dur>', 'Expiry like 24h / 7d / 30d'],
|
|
@@ -14,7 +14,7 @@ export default async function filesDownload(...args) {
|
|
|
14
14
|
const { entry } = await resolveFileRef(ref);
|
|
15
15
|
if (!isJsonMode())
|
|
16
16
|
console.log(`\n ${chalk.dim(`fetching ${entry.name}…`)}`);
|
|
17
|
-
const finalPath = await downloadFile({ id: entry.id, name: entry.name }, opts.out ? path.resolve(String(opts.out)) : undefined);
|
|
17
|
+
const finalPath = await downloadFile({ id: entry.id, name: entry.name, userId: entry.user_id }, opts.out ? path.resolve(String(opts.out)) : undefined);
|
|
18
18
|
const size = fs.statSync(finalPath).size;
|
|
19
19
|
if (isJsonMode())
|
|
20
20
|
console.log(JSON.stringify({ file: finalPath, bytes: size, id: entry.id }, null, 2));
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { gcm } from '@noble/ciphers/aes.js';
|
|
2
|
+
import { hkdf } from '@noble/hashes/hkdf.js';
|
|
3
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
4
|
+
import { randomBytes } from '@noble/hashes/utils.js';
|
|
5
|
+
/* res54 chunk-format support for the CLI — mirrors the platform's
|
|
6
|
+
encryption-v3 / v2 / legacy layouts exactly so every stored file
|
|
7
|
+
decrypts locally without server-side help. */
|
|
8
|
+
export const V3_MAGIC = new Uint8Array([0x52, 0x45, 0x03]);
|
|
9
|
+
const KEY_LEN = 32;
|
|
10
|
+
const IV_LEN = 12;
|
|
11
|
+
export function utf8(n) {
|
|
12
|
+
return typeof n === 'string' ? new TextEncoder().encode(n) : n;
|
|
13
|
+
}
|
|
14
|
+
function concat(...parts) {
|
|
15
|
+
const total = parts.reduce((s, p) => s + p.length, 0);
|
|
16
|
+
const out = new Uint8Array(total);
|
|
17
|
+
let o = 0;
|
|
18
|
+
for (const p of parts) {
|
|
19
|
+
out.set(p, o);
|
|
20
|
+
o += p.length;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
function u32le(n) {
|
|
25
|
+
const b = new Uint8Array(4);
|
|
26
|
+
new DataView(b.buffer).setUint32(0, n, true);
|
|
27
|
+
return b;
|
|
28
|
+
}
|
|
29
|
+
/** Platform key normalisation: key.slice(0,32).padEnd(32,'0') as UTF-8 bytes. */
|
|
30
|
+
export function deriveRawKey(keyString) {
|
|
31
|
+
return utf8(keyString.slice(0, 32).padEnd(32, '0'));
|
|
32
|
+
}
|
|
33
|
+
function hkdfExpand(ikm, info, len) {
|
|
34
|
+
return hkdf(sha256, ikm, new Uint8Array(0), info, len);
|
|
35
|
+
}
|
|
36
|
+
export function detectChunkVersion(blob) {
|
|
37
|
+
if (blob.length > 3 && blob[0] === V3_MAGIC[0] && blob[1] === V3_MAGIC[1] && blob[2] === V3_MAGIC[2])
|
|
38
|
+
return 3;
|
|
39
|
+
if (blob.length > 4 + 12 + 16) {
|
|
40
|
+
const adLen = new DataView(blob.buffer, blob.byteOffset).getUint32(0, true);
|
|
41
|
+
if (adLen >= 8 && adLen <= 128 && blob.length > 4 + adLen + 12)
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
/** v3: [magic3][origLen u32le][iv12][AES-GCM ct||tag] */
|
|
47
|
+
export function decryptV3(blob, masterRaw, userId, fileId, chunkIndex, totalChunks) {
|
|
48
|
+
const fileKeyRaw = hkdfExpand(masterRaw, concat(utf8('v3-file-master'), utf8(fileId)), KEY_LEN);
|
|
49
|
+
const idx = u32le(chunkIndex);
|
|
50
|
+
const chunkKey = hkdfExpand(fileKeyRaw, concat(utf8('v3-chunk'), idx), KEY_LEN);
|
|
51
|
+
const iv = blob.slice(7, 7 + IV_LEN);
|
|
52
|
+
const ct = blob.slice(7 + IV_LEN);
|
|
53
|
+
const aad = utf8(`res54-v3|${userId}|${fileId}|${chunkIndex}|${totalChunks}`);
|
|
54
|
+
const aes = gcm(chunkKey, iv, aad);
|
|
55
|
+
return aes.decrypt(ct);
|
|
56
|
+
}
|
|
57
|
+
/** v2: [4B adLen LE][AD][12B iv][ct] — key = raw utf8 of key string */
|
|
58
|
+
export function decryptV2(blob, keyRaw) {
|
|
59
|
+
const adLen = new DataView(blob.buffer, blob.byteOffset).getUint32(0, true);
|
|
60
|
+
const ad = blob.slice(4, 4 + adLen);
|
|
61
|
+
const iv = blob.slice(4 + adLen, 4 + adLen + 12);
|
|
62
|
+
const ct = blob.slice(4 + adLen + 12);
|
|
63
|
+
const aes = gcm(keyRaw, iv, ad);
|
|
64
|
+
return aes.decrypt(ct);
|
|
65
|
+
}
|
|
66
|
+
/** legacy: [12B iv][ct] */
|
|
67
|
+
export function decryptLegacy(blob, keyRaw) {
|
|
68
|
+
const aes = gcm(keyRaw, blob.slice(0, 12));
|
|
69
|
+
return aes.decrypt(blob.slice(12));
|
|
70
|
+
}
|
|
71
|
+
/** Try every known format; throws only if none succeed. */
|
|
72
|
+
export function decryptAny(blob, keyString, ctx) {
|
|
73
|
+
const attempts = [];
|
|
74
|
+
const version = detectChunkVersion(blob);
|
|
75
|
+
const keyRaw = deriveRawKey(keyString);
|
|
76
|
+
if (version === 3 && ctx?.userId && ctx.fileId) {
|
|
77
|
+
attempts.push(() => decryptV3(blob, keyRaw, ctx.userId, ctx.fileId, ctx.chunkIndex ?? 0, ctx.totalChunks ?? 1));
|
|
78
|
+
}
|
|
79
|
+
attempts.push(() => decryptV2(blob, keyRaw));
|
|
80
|
+
attempts.push(() => decryptLegacy(blob, keyRaw));
|
|
81
|
+
let lastErr = null;
|
|
82
|
+
for (const attempt of attempts) {
|
|
83
|
+
try {
|
|
84
|
+
return { plain: attempt(), version };
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
lastErr = e;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`Decryption failed (${blob.length}B, v${version})${lastErr ? `: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}` : ''}`);
|
|
91
|
+
}
|
|
92
|
+
export function generateKeyHex() {
|
|
93
|
+
return Buffer.from(randomBytes(32)).toString('hex');
|
|
94
|
+
}
|
package/dist/lib/transfer.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { Readable } from 'node:stream';
|
|
4
4
|
import { pipeline } from 'node:stream/promises';
|
|
5
|
-
import { apiRaw, apiPost } from './api/client.js';
|
|
5
|
+
import { apiRaw, apiGet, apiPost } from './api/client.js';
|
|
6
6
|
const CONCURRENCY = 4;
|
|
7
7
|
const CHUNK_RETRIES = 3;
|
|
8
8
|
function humanBytes(n) {
|
|
@@ -21,9 +21,8 @@ export function renderProgressBar(current, total, width = 26) {
|
|
|
21
21
|
if (total <= 0)
|
|
22
22
|
return '░'.repeat(width);
|
|
23
23
|
const filled = Math.round((current / total) * width);
|
|
24
|
-
return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled));
|
|
24
|
+
return '█'.repeat(Math.max(0, Math.min(width, filled))) + '░'.repeat(Math.max(0, width - filled));
|
|
25
25
|
}
|
|
26
|
-
/** Single-line live progress renderer for TTYs. */
|
|
27
26
|
export class TransferProgress {
|
|
28
27
|
label;
|
|
29
28
|
totalBytes;
|
|
@@ -41,21 +40,18 @@ export class TransferProgress {
|
|
|
41
40
|
const pct = this.totalBytes > 0 ? Math.min(100, (currentBytes / this.totalBytes) * 100) : 0;
|
|
42
41
|
const elapsed = (Date.now() - this.startedAt) / 1000;
|
|
43
42
|
const bps = elapsed > 0 ? currentBytes / elapsed : 0;
|
|
44
|
-
const eta = bps > 0 && currentBytes > 0
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
: '—';
|
|
43
|
+
const eta = bps > 0 && currentBytes > 0 && currentBytes < this.totalBytes
|
|
44
|
+
? (this.totalBytes - currentBytes) / bps : Infinity;
|
|
45
|
+
const etaText = Number.isFinite(eta) ? (eta > 90 ? `${Math.round(eta / 60)}m` : `${Math.round(eta)}s`) : '—';
|
|
48
46
|
const line = `${this.label} ${renderProgressBar(currentBytes, this.totalBytes)} ${pct.toFixed(0)}% ${humanBytes(currentBytes)}/${humanBytes(this.totalBytes)} ${humanBytes(bps)}/s eta ${etaText}${extra ? ` ${extra}` : ''}`;
|
|
49
47
|
if (line !== this.lastLine) {
|
|
50
48
|
process.stdout.write(`\r\x1b[2K${line}`);
|
|
51
49
|
this.lastLine = line;
|
|
52
50
|
}
|
|
53
51
|
}
|
|
54
|
-
finish(
|
|
52
|
+
finish() {
|
|
55
53
|
if (this.enabled)
|
|
56
54
|
process.stdout.write('\r\x1b[2K');
|
|
57
|
-
if (!ok && !this.enabled)
|
|
58
|
-
return;
|
|
59
55
|
}
|
|
60
56
|
}
|
|
61
57
|
export async function uploadFile(filePath, opts = {}) {
|
|
@@ -78,16 +74,15 @@ export async function uploadFile(filePath, opts = {}) {
|
|
|
78
74
|
const data = await fs.promises.readFile(filePath);
|
|
79
75
|
const chunkSize = init.chunk_size || 512 * 1024;
|
|
80
76
|
const totalChunks = Math.max(1, Math.ceil(data.length / chunkSize));
|
|
81
|
-
let completedBytes = 0;
|
|
82
|
-
// Parallel chunk PUTs against signed cluster URLs — same dispatch shape as the web app.
|
|
83
77
|
const urlByIndex = new Map(init.urls.map((u) => [u.index, u]));
|
|
84
78
|
const queue = Array.from({ length: totalChunks }, (_, i) => i);
|
|
79
|
+
let completedBytes = 0;
|
|
85
80
|
let done = 0;
|
|
86
81
|
const worker = async () => {
|
|
87
|
-
|
|
82
|
+
for (;;) {
|
|
88
83
|
const idx = queue.shift();
|
|
89
84
|
if (idx === undefined)
|
|
90
|
-
|
|
85
|
+
return;
|
|
91
86
|
const target = urlByIndex.get(idx);
|
|
92
87
|
if (!target?.upload_url)
|
|
93
88
|
throw new Error(`Missing upload URL for chunk ${idx}`);
|
|
@@ -97,20 +92,20 @@ export async function uploadFile(filePath, opts = {}) {
|
|
|
97
92
|
let attempt = 0;
|
|
98
93
|
for (;;) {
|
|
99
94
|
try {
|
|
100
|
-
const
|
|
95
|
+
const r = await fetch(target.upload_url, {
|
|
101
96
|
method: 'PUT',
|
|
102
97
|
headers: { 'Content-Type': 'application/octet-stream' },
|
|
103
98
|
body: new Uint8Array(slice),
|
|
104
99
|
});
|
|
105
|
-
if (!
|
|
106
|
-
throw new Error(`
|
|
100
|
+
if (!r.ok)
|
|
101
|
+
throw new Error(`chunk ${idx} HTTP ${r.status}`);
|
|
107
102
|
break;
|
|
108
103
|
}
|
|
109
104
|
catch (err) {
|
|
110
105
|
attempt++;
|
|
111
106
|
if (attempt >= CHUNK_RETRIES)
|
|
112
107
|
throw err;
|
|
113
|
-
await new Promise((
|
|
108
|
+
await new Promise((r2) => setTimeout(r2, 500 * 2 ** attempt));
|
|
114
109
|
}
|
|
115
110
|
}
|
|
116
111
|
done++;
|
|
@@ -120,38 +115,254 @@ export async function uploadFile(filePath, opts = {}) {
|
|
|
120
115
|
}
|
|
121
116
|
};
|
|
122
117
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, totalChunks) }, worker));
|
|
123
|
-
progress.finish(
|
|
118
|
+
progress.finish();
|
|
124
119
|
const complete = await apiPost('/upload/complete', {
|
|
125
120
|
upload_id: init.upload_id,
|
|
126
121
|
chunk_size: chunkSize,
|
|
127
122
|
});
|
|
128
|
-
return {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
};
|
|
123
|
+
return { id: complete?.id || complete?.file_id || init.file_id, name, size: stat.size };
|
|
124
|
+
}
|
|
125
|
+
const isUsableKey = (k) => !!k && !/^(managed_key|sha256:.*|byok_encrypted|byok_protected)$/.test(k);
|
|
126
|
+
async function fetchVaultKey(fileId) {
|
|
127
|
+
const { SUPABASE_URL } = await import('./auth/constants.js');
|
|
128
|
+
const { authToken, anonKey } = await import('./auth/oauth-session.js');
|
|
129
|
+
if (!authToken)
|
|
130
|
+
return null;
|
|
131
|
+
try {
|
|
132
|
+
const r = await fetch(`${SUPABASE_URL}/functions/v1/file-key`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: {
|
|
135
|
+
'Content-Type': 'application/json',
|
|
136
|
+
Authorization: `Bearer ${authToken}`,
|
|
137
|
+
...(anonKey ? { apikey: anonKey } : {}),
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify({ fileId }),
|
|
140
|
+
});
|
|
141
|
+
const d = (await r.json());
|
|
142
|
+
return d?.success && d.key ? d.key : null;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function getBridgeKey() {
|
|
149
|
+
if (process.env.SQUIDCLOUD_API_KEY)
|
|
150
|
+
return process.env.SQUIDCLOUD_API_KEY;
|
|
151
|
+
const fs2 = await import('node:fs');
|
|
152
|
+
const os2 = await import('node:os');
|
|
153
|
+
const path2 = await import('node:path');
|
|
154
|
+
const keyFile = path2.join(os2.homedir(), '.squidcloud', 'bridge-key.json');
|
|
155
|
+
try {
|
|
156
|
+
const cached = JSON.parse(fs2.readFileSync(keyFile, 'utf8'));
|
|
157
|
+
if (cached?.key?.startsWith('cb_'))
|
|
158
|
+
return cached.key;
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
const created = await apiPost('/keys', {
|
|
162
|
+
name: 'cli-bridge-' + new Date().toISOString().slice(0, 10),
|
|
163
|
+
});
|
|
164
|
+
const key = created?.raw_key;
|
|
165
|
+
if (!key)
|
|
166
|
+
throw new Error('Could not provision bridge key');
|
|
167
|
+
try {
|
|
168
|
+
fs2.mkdirSync(path2.dirname(keyFile), { recursive: true });
|
|
169
|
+
fs2.writeFileSync(keyFile, JSON.stringify({ key, id: created?.key?.id }), { mode: 0o600 });
|
|
170
|
+
}
|
|
171
|
+
catch { }
|
|
172
|
+
return key;
|
|
173
|
+
}
|
|
174
|
+
function parseTags(rawTags) {
|
|
175
|
+
let cur = rawTags;
|
|
176
|
+
for (let i = 0; i < 3; i++) {
|
|
177
|
+
if (typeof cur === 'string') {
|
|
178
|
+
try {
|
|
179
|
+
cur = JSON.parse(cur);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (Array.isArray(cur)) {
|
|
186
|
+
cur = cur[0];
|
|
187
|
+
}
|
|
188
|
+
else
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
if (cur && typeof cur === 'object' && Array.isArray(cur.chunks)) {
|
|
192
|
+
const t = cur;
|
|
193
|
+
return { chunks: t.chunks, encryptionKey: t.encryptionKey };
|
|
194
|
+
}
|
|
195
|
+
return { chunks: [] };
|
|
196
|
+
}
|
|
197
|
+
async function bridgeResolve(apiKey, chunks) {
|
|
198
|
+
const { SUPABASE_URL } = await import('./auth/constants.js');
|
|
199
|
+
const { authToken, anonKey } = await import('./auth/oauth-session.js');
|
|
200
|
+
if (!apiKey && !authToken)
|
|
201
|
+
throw new Error('Not authenticated');
|
|
202
|
+
const r = await fetch(`${SUPABASE_URL}/functions/v1/squidfs-bridge`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: {
|
|
205
|
+
'Content-Type': 'application/json',
|
|
206
|
+
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
|
207
|
+
...(anonKey ? { apikey: anonKey } : {}),
|
|
208
|
+
},
|
|
209
|
+
body: JSON.stringify({ api_key: apiKey ?? '', action: 'resolve_download', chunks }),
|
|
210
|
+
});
|
|
211
|
+
const d = (await r.json());
|
|
212
|
+
if (!r.ok || !d.success || !Array.isArray(d.urls))
|
|
213
|
+
throw new Error(d.error || `bridge HTTP ${r.status}`);
|
|
214
|
+
const map = new Map();
|
|
215
|
+
for (const u of d.urls)
|
|
216
|
+
if (u.downloadUrl)
|
|
217
|
+
map.set(u.index ?? 0, u.downloadUrl);
|
|
218
|
+
return chunks.map((c) => {
|
|
219
|
+
const u = map.get(c.index);
|
|
220
|
+
if (!u)
|
|
221
|
+
throw new Error(`bridge missing URL for chunk ${c.index}`);
|
|
222
|
+
return u;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
export async function downloadFile(target, destPath, onProgress) {
|
|
226
|
+
const errors = [];
|
|
227
|
+
// Authoritative chunk list (also covers SquidFS-written files).
|
|
228
|
+
let chunks = [];
|
|
229
|
+
let tagsKey;
|
|
230
|
+
let encrypted = false;
|
|
231
|
+
try {
|
|
232
|
+
const metaRow = await apiGet(`/files/${target.id}/metadata`);
|
|
233
|
+
const row = metaRow.file ?? {};
|
|
234
|
+
encrypted = row.encrypted === true;
|
|
235
|
+
const parsed = parseTags(row.tags);
|
|
236
|
+
chunks = parsed.chunks;
|
|
237
|
+
tagsKey = parsed.encryptionKey;
|
|
238
|
+
if (!target.userId && typeof row.user_id === 'string')
|
|
239
|
+
target.userId = row.user_id;
|
|
240
|
+
}
|
|
241
|
+
catch { /* metadata optional */ }
|
|
242
|
+
const hasB2 = chunks.length > 0 && chunks.every((c) => c.repo === 'b2');
|
|
243
|
+
const hasBridgeOnly = chunks.length > 0 && !hasB2;
|
|
244
|
+
// 1) Signed API mode for platform-stored chunks.
|
|
245
|
+
if (hasB2 || chunks.length === 0) {
|
|
246
|
+
try {
|
|
247
|
+
const res = await apiRaw(`/download/${target.id}?signed=true`);
|
|
248
|
+
if (res.ok) {
|
|
249
|
+
const meta = (await res.json());
|
|
250
|
+
if (meta?.success && Array.isArray(meta.signed_urls) && meta.signed_urls.length > 0) {
|
|
251
|
+
return await fetchDecryptWrite(meta.signed_urls, target, destPath, onProgress);
|
|
252
|
+
}
|
|
253
|
+
errors.push('signed: empty urls');
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
errors.push(`signed: HTTP ${res.status}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
errors.push(`signed: ${e instanceof Error ? e.message : String(e)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// 2) Bridge mode (SquidFS-origin files).
|
|
264
|
+
if (hasBridgeOnly) {
|
|
265
|
+
try {
|
|
266
|
+
const apiKey = process.env.SQUIDCLOUD_API_KEY ?? null;
|
|
267
|
+
const urls = await bridgeResolve(apiKey, chunks.map((c) => ({ path: c.path, index: c.index, bucket: c.bucket })));
|
|
268
|
+
return await fetchDecryptWrite(urls, target, destPath, onProgress);
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
errors.push(`bridge: ${e instanceof Error ? e.message : String(e)}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// 3) Plain server-decrypted stream (non-distributed storage).
|
|
275
|
+
if (!encrypted && chunks.length === 0) {
|
|
276
|
+
try {
|
|
277
|
+
return await streamPlain(`/download/${target.id}`, target.name, destPath, onProgress);
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
errors.push(`plain: ${e instanceof Error ? e.message : String(e)}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
throw new Error(errors.join(' · ') || 'No downloadable strategy succeeded');
|
|
284
|
+
}
|
|
285
|
+
async function resolveKey(fileId, tagsKey) {
|
|
286
|
+
if (tagsKey && !/^(managed_key|sha256:\S+|byok_encrypted|byok_protected)$/.test(tagsKey))
|
|
287
|
+
return tagsKey;
|
|
288
|
+
return fetchVaultKey(fileId);
|
|
289
|
+
}
|
|
290
|
+
async function fetchDecryptWrite(urls, target, destPath, onProgress) {
|
|
291
|
+
const totalChunks = urls.length;
|
|
292
|
+
const finalPath = destPath || target.name;
|
|
293
|
+
const out = fs.createWriteStream(finalPath);
|
|
294
|
+
const progress = new TransferProgress('download', target.size ?? 0, process.stdout.isTTY);
|
|
295
|
+
let keyString;
|
|
296
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
297
|
+
const r = await fetch(urls[i % urls.length]);
|
|
298
|
+
if (!r.ok)
|
|
299
|
+
throw new Error(`chunk ${i} HTTP ${r.status}`);
|
|
300
|
+
const blob = new Uint8Array(await r.arrayBuffer());
|
|
301
|
+
const { detectChunkVersion, decryptAny } = await import('./crypto/res54.js');
|
|
302
|
+
const version = detectChunkVersion(blob);
|
|
303
|
+
if (version === 1 && blob.length <= 28) {
|
|
304
|
+
out.write(Buffer.from(blob));
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (keyString === undefined) {
|
|
308
|
+
keyString = (await resolveKey(target.id)) ?? '';
|
|
309
|
+
if (!keyString) {
|
|
310
|
+
out.end();
|
|
311
|
+
throw new Error('Encrypted file but its key is unavailable (BYOK without key?)');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
let plain;
|
|
315
|
+
try {
|
|
316
|
+
plain = decryptAny(blob, keyString, {
|
|
317
|
+
userId: target.userId,
|
|
318
|
+
fileId: target.id,
|
|
319
|
+
chunkIndex: i,
|
|
320
|
+
totalChunks,
|
|
321
|
+
}).plain;
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
if (version === 1) {
|
|
325
|
+
plain = blob;
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
out.end();
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
progress.update(i + 1, `chunk ${i + 1}/${totalChunks}`);
|
|
333
|
+
void onProgress;
|
|
334
|
+
const buf = Buffer.from(plain);
|
|
335
|
+
if (!out.write(buf))
|
|
336
|
+
await new Promise((r2) => out.once('drain', () => r2()));
|
|
337
|
+
}
|
|
338
|
+
out.end();
|
|
339
|
+
await new Promise((resolve, reject) => {
|
|
340
|
+
out.on('finish', () => resolve());
|
|
341
|
+
out.on('error', reject);
|
|
342
|
+
});
|
|
343
|
+
progress.finish();
|
|
344
|
+
return finalPath;
|
|
133
345
|
}
|
|
134
|
-
|
|
135
|
-
const res = await apiRaw(
|
|
346
|
+
async function streamPlain(reqPath, fallbackName, destPath, onProgress) {
|
|
347
|
+
const res = await apiRaw(reqPath);
|
|
136
348
|
if (!res.ok) {
|
|
137
|
-
const
|
|
138
|
-
throw new Error(
|
|
349
|
+
const t = await res.text().catch(() => '');
|
|
350
|
+
throw new Error(t.slice(0, 300) || `Download failed (HTTP ${res.status})`);
|
|
139
351
|
}
|
|
140
352
|
const total = Number(res.headers.get('content-length')) || 0;
|
|
141
353
|
const progress = new TransferProgress('download', total, process.stdout.isTTY);
|
|
142
354
|
const nodeStream = Readable.fromWeb(res.body);
|
|
143
355
|
const header = res.headers.get('content-disposition') || '';
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
const finalPath = destPath || fileName;
|
|
356
|
+
const m = header.match(/filename="?([^";]+)"?/i);
|
|
357
|
+
const finalPath = destPath || m?.[1] || fallbackName;
|
|
147
358
|
let received = 0;
|
|
148
|
-
nodeStream.on('data', (
|
|
149
|
-
received +=
|
|
359
|
+
nodeStream.on('data', (c) => {
|
|
360
|
+
received += c.length;
|
|
150
361
|
onProgress?.({ current: received, total: total || received, stage: 'downloading' });
|
|
151
362
|
progress.update(received);
|
|
152
363
|
});
|
|
153
364
|
await pipeline(nodeStream, fs.createWriteStream(finalPath));
|
|
154
|
-
progress.finish(
|
|
365
|
+
progress.finish();
|
|
155
366
|
return finalPath;
|
|
156
367
|
}
|
|
157
368
|
function guessMime(name) {
|
|
@@ -166,3 +377,4 @@ function guessMime(name) {
|
|
|
166
377
|
};
|
|
167
378
|
return table[ext] || 'application/octet-stream';
|
|
168
379
|
}
|
|
380
|
+
export { apiPost };
|