bytifi 0.1.1 → 0.1.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/lib/crypto.js CHANGED
@@ -200,6 +200,49 @@ export function importToken(token) {
200
200
  return tokenBytes
201
201
  }
202
202
 
203
+ export function normalizeClientEncryptionMeta(rawMeta) {
204
+ if (!rawMeta || typeof rawMeta !== 'object') return null
205
+
206
+ const chunkSize = Number(rawMeta.chunkSize)
207
+ const chunkCount = Number(rawMeta.chunkCount)
208
+ const originalSize = Number(rawMeta.originalSize)
209
+
210
+ if (!Number.isFinite(chunkSize) || chunkSize <= 0) return null
211
+ if (!Number.isFinite(chunkCount) || chunkCount <= 0) return null
212
+ if (!Number.isFinite(originalSize) || originalSize < 0) return null
213
+ if (typeof rawMeta.noncePrefix !== 'string' || !rawMeta.noncePrefix) return null
214
+
215
+ return {
216
+ version: Number(rawMeta.version) || 1,
217
+ algorithm: String(rawMeta.algorithm || 'AES-GCM'),
218
+ chunkSize,
219
+ chunkCount,
220
+ noncePrefix: rawMeta.noncePrefix,
221
+ originalSize,
222
+ mimeType: String(rawMeta.mimeType || 'application/octet-stream'),
223
+ }
224
+ }
225
+
226
+ export function buildEncryptedChunkPlan(meta) {
227
+ const chunks = []
228
+ let offset = 0
229
+
230
+ for (let chunkIndex = 0; chunkIndex < meta.chunkCount; chunkIndex += 1) {
231
+ const start = chunkIndex * meta.chunkSize
232
+ const plainSize = Math.min(meta.originalSize - start, meta.chunkSize)
233
+ const encryptedSize = plainSize + 16
234
+
235
+ chunks.push({ chunkIndex, encryptedSize, plainSize })
236
+ offset += encryptedSize
237
+ }
238
+
239
+ return {
240
+ chunks,
241
+ totalEncryptedSize: offset,
242
+ totalPlainSize: meta.originalSize,
243
+ }
244
+ }
245
+
203
246
  function guessMimeType(filename) {
204
247
  const ext = path.extname(filename).toLowerCase()
205
248
  const map = {
package/lib/decrypt.js ADDED
@@ -0,0 +1,664 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import process from 'node:process'
4
+ import {
5
+ buildEncryptedChunkPlan,
6
+ decryptChunk,
7
+ importToken,
8
+ normalizeClientEncryptionMeta,
9
+ } from './crypto.js'
10
+ import { fromBase64Url } from './base64url.js'
11
+ import { BytifiApiError, BytifiNetworkError } from './upload.js'
12
+
13
+ const DEFAULT_BASE_URL = 'https://bytifi.com'
14
+
15
+ function normalizeBaseUrl(baseUrl) {
16
+ return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
17
+ }
18
+
19
+ async function readResponseBody(response) {
20
+ const text = await response.text()
21
+ if (!text) return null
22
+
23
+ try {
24
+ return JSON.parse(text)
25
+ } catch {
26
+ return text
27
+ }
28
+ }
29
+
30
+ async function publicFetch(baseUrl, requestPath, { signal } = {}) {
31
+ const url = `${normalizeBaseUrl(baseUrl)}${requestPath}`
32
+
33
+ let response
34
+
35
+ try {
36
+ response = await fetch(url, { signal })
37
+ } catch (error) {
38
+ if (signal?.aborted) {
39
+ throw new Error('Decrypt aborted.')
40
+ }
41
+ throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
42
+ }
43
+
44
+ const payload = await readResponseBody(response)
45
+
46
+ if (!response.ok) {
47
+ const message = typeof payload === 'object' && payload?.error
48
+ ? payload.error
49
+ : typeof payload === 'string' && payload
50
+ ? payload
51
+ : `Request failed with status ${response.status}.`
52
+ throw new BytifiApiError(message, { status: response.status, body: payload })
53
+ }
54
+
55
+ return payload
56
+ }
57
+
58
+ export function parseDecryptInput(input, { encryptionToken = '', baseUrl = DEFAULT_BASE_URL } = {}) {
59
+ const trimmed = String(input || '').trim()
60
+ if (!trimmed) {
61
+ throw new Error('Missing share URL or link token.')
62
+ }
63
+
64
+ let resolvedBaseUrl = normalizeBaseUrl(baseUrl)
65
+ let linkToken = ''
66
+ let resolvedEncryptionToken = String(encryptionToken || '').trim()
67
+
68
+ if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) {
69
+ const url = new URL(trimmed, `${resolvedBaseUrl}/`)
70
+
71
+ if (url.origin !== 'null:') {
72
+ resolvedBaseUrl = `${url.protocol}//${url.host}`
73
+ }
74
+
75
+ const hashParams = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash)
76
+ if (!resolvedEncryptionToken) {
77
+ resolvedEncryptionToken = hashParams.get('token') || ''
78
+ }
79
+
80
+ linkToken = url.searchParams.get('link') || ''
81
+
82
+ if (!linkToken) {
83
+ const fileMatch = url.pathname.match(/^\/f\/([^/]+)/)
84
+ linkToken = fileMatch?.[1] || ''
85
+ }
86
+ } else {
87
+ linkToken = trimmed
88
+ }
89
+
90
+ if (!linkToken) {
91
+ throw new Error('Could not find a link token in the input URL.')
92
+ }
93
+
94
+ if (!resolvedEncryptionToken) {
95
+ throw new Error('Missing encryption token. Include #token=... in the URL or pass --token.')
96
+ }
97
+
98
+ return {
99
+ baseUrl: resolvedBaseUrl,
100
+ linkToken,
101
+ encryptionToken: resolvedEncryptionToken,
102
+ }
103
+ }
104
+
105
+ export function parseShareReference(input, { encryptionToken = '', baseUrl = DEFAULT_BASE_URL } = {}) {
106
+ const trimmed = String(input || '').trim()
107
+ if (!trimmed) {
108
+ return {
109
+ baseUrl: normalizeBaseUrl(baseUrl),
110
+ linkToken: '',
111
+ encryptionToken: String(encryptionToken || '').trim(),
112
+ }
113
+ }
114
+
115
+ if (!/^https?:\/\//i.test(trimmed) && !trimmed.startsWith('/')) {
116
+ return {
117
+ baseUrl: normalizeBaseUrl(baseUrl),
118
+ linkToken: trimmed,
119
+ encryptionToken: String(encryptionToken || '').trim(),
120
+ }
121
+ }
122
+
123
+ const url = new URL(trimmed, `${normalizeBaseUrl(baseUrl)}/`)
124
+ const hashParams = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash)
125
+
126
+ return {
127
+ baseUrl: `${url.protocol}//${url.host}`,
128
+ linkToken: url.searchParams.get('link') || url.pathname.match(/^\/f\/([^/]+)/)?.[1] || '',
129
+ encryptionToken: String(encryptionToken || hashParams.get('token') || '').trim(),
130
+ }
131
+ }
132
+
133
+ function looksLikeRemoteInput(input) {
134
+ const trimmed = String(input || '').trim()
135
+ return /^https?:\/\//i.test(trimmed)
136
+ || trimmed.startsWith('/link')
137
+ || trimmed.startsWith('/f/')
138
+ }
139
+
140
+ async function pathExists(inputPath) {
141
+ try {
142
+ await fs.access(path.resolve(inputPath))
143
+ return true
144
+ } catch {
145
+ return false
146
+ }
147
+ }
148
+
149
+ async function readMetaFile(metaPath) {
150
+ const raw = await fs.readFile(path.resolve(metaPath), 'utf8')
151
+ const parsed = JSON.parse(raw)
152
+ const meta = normalizeClientEncryptionMeta(parsed?.clientEncryptionMeta || parsed)
153
+
154
+ if (!meta) {
155
+ throw new Error('Invalid encryption metadata file.')
156
+ }
157
+
158
+ return meta
159
+ }
160
+
161
+ async function resolveEncryptionMeta({
162
+ metaPath = '',
163
+ linkToken = '',
164
+ baseUrl = DEFAULT_BASE_URL,
165
+ signal,
166
+ }) {
167
+ if (metaPath) {
168
+ return readMetaFile(metaPath)
169
+ }
170
+
171
+ if (!linkToken) {
172
+ throw new Error('Pass --link to fetch encryption metadata, or --meta with a saved metadata JSON file.')
173
+ }
174
+
175
+ const linkInfo = await fetchLinkInfo(baseUrl, linkToken, signal)
176
+
177
+ if (linkInfo.status === 'expired') {
178
+ throw new Error('This file link has expired.')
179
+ }
180
+
181
+ if (!linkInfo.clientEncrypted) {
182
+ throw new Error('This link is not an encrypted file.')
183
+ }
184
+
185
+ const meta = normalizeClientEncryptionMeta(linkInfo.clientEncryptionMeta)
186
+ if (!meta) {
187
+ throw new Error('Invalid encryption metadata for this file.')
188
+ }
189
+
190
+ return { meta, linkInfo }
191
+ }
192
+
193
+ function sanitizeOutputName(filename) {
194
+ const base = path.basename(String(filename || 'download').replace(/[\0\r\n]/g, ''))
195
+ return base || 'download'
196
+ }
197
+
198
+ async function fetchLinkInfo(baseUrl, linkToken, signal) {
199
+ return publicFetch(baseUrl, `/api/link/${encodeURIComponent(linkToken)}`, { signal })
200
+ }
201
+
202
+ async function fetchEncryptedPart(baseUrl, linkToken, partNumber, signal) {
203
+ const response = await fetch(
204
+ `${normalizeBaseUrl(baseUrl)}/f/${encodeURIComponent(linkToken)}/p/${partNumber}`,
205
+ { signal },
206
+ )
207
+
208
+ if (!response.ok) {
209
+ const text = await response.text()
210
+ throw new BytifiApiError(text || `Failed to download part ${partNumber}.`, {
211
+ status: response.status,
212
+ body: text,
213
+ })
214
+ }
215
+
216
+ return Buffer.from(await response.arrayBuffer())
217
+ }
218
+
219
+ async function decryptFromParts({
220
+ baseUrl,
221
+ linkToken,
222
+ meta,
223
+ tokenBytes,
224
+ noncePrefix,
225
+ outputPath,
226
+ onProgress,
227
+ signal,
228
+ }) {
229
+ const { chunks } = buildEncryptedChunkPlan(meta)
230
+ const fileHandle = await fs.open(outputPath, 'w')
231
+ let decryptedBytes = 0
232
+
233
+ try {
234
+ for (let index = 0; index < chunks.length; index += 1) {
235
+ if (signal?.aborted) {
236
+ throw new Error('Decrypt aborted.')
237
+ }
238
+
239
+ const chunk = chunks[index]
240
+ const encryptedPart = await fetchEncryptedPart(baseUrl, linkToken, chunk.chunkIndex + 1, signal)
241
+ const plainPart = decryptChunk(encryptedPart, tokenBytes, noncePrefix, chunk.chunkIndex)
242
+
243
+ await fileHandle.write(plainPart)
244
+ decryptedBytes += plainPart.length
245
+ onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
246
+ }
247
+ } finally {
248
+ await fileHandle.close()
249
+ }
250
+ }
251
+
252
+ async function decryptFromSingleFile({
253
+ encryptedFileUrl,
254
+ meta,
255
+ tokenBytes,
256
+ noncePrefix,
257
+ outputPath,
258
+ onProgress,
259
+ signal,
260
+ }) {
261
+ const { chunks, totalEncryptedSize } = buildEncryptedChunkPlan(meta)
262
+ const response = await fetch(encryptedFileUrl, { signal })
263
+
264
+ if (!response.ok) {
265
+ const text = await response.text()
266
+ throw new BytifiApiError(text || 'Failed to download encrypted file.', {
267
+ status: response.status,
268
+ body: text,
269
+ })
270
+ }
271
+
272
+ if (!response.body) {
273
+ const encryptedBuffer = Buffer.from(await response.arrayBuffer())
274
+ await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
275
+ return
276
+ }
277
+
278
+ const reader = response.body.getReader()
279
+ const fileHandle = await fs.open(outputPath, 'w')
280
+ const pending = []
281
+ let pendingLength = 0
282
+ let downloadedBytes = 0
283
+ let nextChunkIndex = 0
284
+ let decryptedBytes = 0
285
+
286
+ const takeBytes = (length) => {
287
+ while (pendingLength < length) {
288
+ return null
289
+ }
290
+
291
+ const out = Buffer.alloc(length)
292
+ let offset = 0
293
+
294
+ while (offset < length) {
295
+ const head = pending[0]
296
+ const take = Math.min(length - offset, head.length)
297
+ head.copy(out, offset, 0, take)
298
+ offset += take
299
+
300
+ if (take === head.length) {
301
+ pending.shift()
302
+ } else {
303
+ pending[0] = head.subarray(take)
304
+ }
305
+ pendingLength -= take
306
+ }
307
+
308
+ return out
309
+ }
310
+
311
+ try {
312
+ while (nextChunkIndex < chunks.length) {
313
+ if (signal?.aborted) {
314
+ throw new Error('Decrypt aborted.')
315
+ }
316
+
317
+ const chunk = chunks[nextChunkIndex]
318
+
319
+ while (pendingLength < chunk.encryptedSize) {
320
+ const { done, value } = await reader.read()
321
+ if (done) break
322
+ const buffer = Buffer.from(value)
323
+ pending.push(buffer)
324
+ pendingLength += buffer.length
325
+ downloadedBytes += buffer.length
326
+ onProgress?.(Math.min(99, Math.round((downloadedBytes / totalEncryptedSize) * 100)))
327
+ }
328
+
329
+ const encryptedChunk = takeBytes(chunk.encryptedSize)
330
+ if (!encryptedChunk) {
331
+ throw new Error('Encrypted file ended before all parts were downloaded.')
332
+ }
333
+
334
+ const plainPart = decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunk.chunkIndex)
335
+ await fileHandle.write(plainPart)
336
+ decryptedBytes += plainPart.length
337
+ nextChunkIndex += 1
338
+ onProgress?.(Math.min(99, Math.round((decryptedBytes / meta.originalSize) * 100)))
339
+ }
340
+ } finally {
341
+ await reader.cancel().catch(() => {})
342
+ await fileHandle.close()
343
+ }
344
+
345
+ onProgress?.(100)
346
+ }
347
+
348
+ async function writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal) {
349
+ const fileHandle = await fs.open(outputPath, 'w')
350
+ let offset = 0
351
+ let decryptedBytes = 0
352
+
353
+ try {
354
+ for (const chunk of chunks) {
355
+ if (signal?.aborted) {
356
+ throw new Error('Decrypt aborted.')
357
+ }
358
+
359
+ const encryptedChunk = encryptedBuffer.subarray(offset, offset + chunk.encryptedSize)
360
+ if (encryptedChunk.length !== chunk.encryptedSize) {
361
+ throw new Error('Encrypted file ended before all parts were downloaded.')
362
+ }
363
+
364
+ const plainPart = decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunk.chunkIndex)
365
+ await fileHandle.write(plainPart)
366
+ offset += chunk.encryptedSize
367
+ decryptedBytes += plainPart.length
368
+ onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
369
+ }
370
+ } finally {
371
+ await fileHandle.close()
372
+ }
373
+
374
+ onProgress?.(100)
375
+ }
376
+
377
+ async function decryptFromLocalSingleFile({
378
+ encryptedFilePath,
379
+ meta,
380
+ tokenBytes,
381
+ noncePrefix,
382
+ outputPath,
383
+ onProgress,
384
+ signal,
385
+ }) {
386
+ const { chunks } = buildEncryptedChunkPlan(meta)
387
+ const stat = await fs.stat(encryptedFilePath)
388
+
389
+ if (stat.size <= 64 * 1024 * 1024) {
390
+ const encryptedBuffer = await fs.readFile(encryptedFilePath)
391
+ await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
392
+ return
393
+ }
394
+
395
+ const fileHandle = await fs.open(encryptedFilePath, 'r')
396
+ const outputHandle = await fs.open(outputPath, 'w')
397
+ let offset = 0
398
+ let decryptedBytes = 0
399
+
400
+ try {
401
+ for (const chunk of chunks) {
402
+ if (signal?.aborted) {
403
+ throw new Error('Decrypt aborted.')
404
+ }
405
+
406
+ const encryptedChunk = Buffer.alloc(chunk.encryptedSize)
407
+ const { bytesRead } = await fileHandle.read(encryptedChunk, 0, chunk.encryptedSize, offset)
408
+
409
+ if (bytesRead !== chunk.encryptedSize) {
410
+ throw new Error('Encrypted file ended before all parts were read.')
411
+ }
412
+
413
+ const plainPart = decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunk.chunkIndex)
414
+ await outputHandle.write(plainPart)
415
+ offset += chunk.encryptedSize
416
+ decryptedBytes += plainPart.length
417
+ onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
418
+ }
419
+ } finally {
420
+ await fileHandle.close()
421
+ await outputHandle.close()
422
+ }
423
+
424
+ onProgress?.(100)
425
+ }
426
+
427
+ async function listLocalPartFiles(dirPath) {
428
+ const entries = await fs.readdir(dirPath)
429
+ const parts = entries
430
+ .map((name) => {
431
+ const match = name.match(/^(\d+)(?:\.part)?$/)
432
+ return match ? { partNumber: Number(match[1]), name } : null
433
+ })
434
+ .filter(Boolean)
435
+ .sort((left, right) => left.partNumber - right.partNumber)
436
+
437
+ if (parts.length === 0) {
438
+ throw new Error('No numbered part files found in directory. Expected names like 1, 2, or 1.part, 2.part.')
439
+ }
440
+
441
+ return parts
442
+ }
443
+
444
+ async function decryptFromLocalParts({
445
+ partsDirectory,
446
+ meta,
447
+ tokenBytes,
448
+ noncePrefix,
449
+ outputPath,
450
+ onProgress,
451
+ signal,
452
+ }) {
453
+ const { chunks } = buildEncryptedChunkPlan(meta)
454
+ const parts = await listLocalPartFiles(partsDirectory)
455
+
456
+ if (parts.length !== chunks.length) {
457
+ throw new Error(`Expected ${chunks.length} part files, found ${parts.length}.`)
458
+ }
459
+
460
+ const fileHandle = await fs.open(outputPath, 'w')
461
+ let decryptedBytes = 0
462
+
463
+ try {
464
+ for (const chunk of chunks) {
465
+ if (signal?.aborted) {
466
+ throw new Error('Decrypt aborted.')
467
+ }
468
+
469
+ const partNumber = chunk.chunkIndex + 1
470
+ const partEntry = parts[chunk.chunkIndex]
471
+
472
+ if (!partEntry || partEntry.partNumber !== partNumber) {
473
+ throw new Error(`Missing local part file ${partNumber}.`)
474
+ }
475
+
476
+ const encryptedPart = await fs.readFile(path.join(partsDirectory, partEntry.name))
477
+ const plainPart = decryptChunk(encryptedPart, tokenBytes, noncePrefix, chunk.chunkIndex)
478
+
479
+ await fileHandle.write(plainPart)
480
+ decryptedBytes += plainPart.length
481
+ onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
482
+ }
483
+ } finally {
484
+ await fileHandle.close()
485
+ }
486
+
487
+ onProgress?.(100)
488
+ }
489
+
490
+ function resolveEncryptionToken(encryptionToken, shareReference) {
491
+ const resolved = String(encryptionToken || shareReference?.encryptionToken || '').trim()
492
+
493
+ if (!resolved) {
494
+ throw new Error('Missing encryption token. Pass --token or include #token=... in --share-url.')
495
+ }
496
+
497
+ return resolved
498
+ }
499
+
500
+ function buildDecryptResult({
501
+ outputPath,
502
+ originalName,
503
+ size,
504
+ mimeType,
505
+ expiresAt = null,
506
+ linkToken = '',
507
+ storageMode = 'single',
508
+ sourcePath = '',
509
+ }) {
510
+ return {
511
+ outputPath,
512
+ originalName,
513
+ size,
514
+ mimeType,
515
+ expiresAt,
516
+ linkToken,
517
+ storageMode,
518
+ sourcePath,
519
+ }
520
+ }
521
+
522
+ async function decryptLocalFile(inputPath, options = {}) {
523
+ const absolutePath = path.resolve(inputPath)
524
+ const stat = await fs.stat(absolutePath)
525
+ const shareReference = parseShareReference(options.shareUrl || '', {
526
+ encryptionToken: options.encryptionToken,
527
+ baseUrl: options.baseUrl,
528
+ })
529
+ const linkToken = options.linkToken || shareReference.linkToken
530
+ const resolved = await resolveEncryptionMeta({
531
+ metaPath: options.metaPath,
532
+ linkToken,
533
+ baseUrl: options.baseUrl || shareReference.baseUrl,
534
+ signal: options.signal,
535
+ })
536
+ const meta = resolved.meta || resolved
537
+ const linkInfo = resolved.linkInfo || null
538
+ const encryptionToken = resolveEncryptionToken(options.encryptionToken, shareReference)
539
+ const tokenBytes = importToken(encryptionToken)
540
+ const noncePrefix = fromBase64Url(meta.noncePrefix)
541
+ const originalName = linkInfo?.originalName || options.originalName || 'download'
542
+ const outputName = sanitizeOutputName(options.output ? path.basename(options.output) : originalName)
543
+ const outputPath = path.resolve(options.output || path.join(options.outputDirectory || process.cwd(), outputName))
544
+
545
+ await fs.mkdir(path.dirname(outputPath), { recursive: true })
546
+
547
+ if (stat.isDirectory()) {
548
+ await decryptFromLocalParts({
549
+ partsDirectory: absolutePath,
550
+ meta,
551
+ tokenBytes,
552
+ noncePrefix,
553
+ outputPath,
554
+ onProgress: options.onProgress,
555
+ signal: options.signal,
556
+ })
557
+
558
+ return buildDecryptResult({
559
+ outputPath,
560
+ originalName,
561
+ size: meta.originalSize,
562
+ mimeType: meta.mimeType,
563
+ expiresAt: linkInfo?.expiresAt || null,
564
+ linkToken,
565
+ storageMode: 'parts',
566
+ sourcePath: absolutePath,
567
+ })
568
+ }
569
+
570
+ await decryptFromLocalSingleFile({
571
+ encryptedFilePath: absolutePath,
572
+ meta,
573
+ tokenBytes,
574
+ noncePrefix,
575
+ outputPath,
576
+ onProgress: options.onProgress,
577
+ signal: options.signal,
578
+ })
579
+
580
+ return buildDecryptResult({
581
+ outputPath,
582
+ originalName,
583
+ size: meta.originalSize,
584
+ mimeType: meta.mimeType,
585
+ expiresAt: linkInfo?.expiresAt || null,
586
+ linkToken,
587
+ storageMode: 'single',
588
+ sourcePath: absolutePath,
589
+ })
590
+ }
591
+
592
+ export async function decryptFile(input, options = {}) {
593
+ const trimmedInput = String(input || '').trim()
594
+ if (!trimmedInput) {
595
+ throw new Error('Missing share URL, link token, or encrypted file path.')
596
+ }
597
+
598
+ if (options.localFile || (!looksLikeRemoteInput(trimmedInput) && await pathExists(trimmedInput))) {
599
+ return decryptLocalFile(trimmedInput, options)
600
+ }
601
+
602
+ const parsed = parseDecryptInput(trimmedInput, {
603
+ encryptionToken: options.encryptionToken,
604
+ baseUrl: options.baseUrl,
605
+ })
606
+
607
+ const linkInfo = await fetchLinkInfo(parsed.baseUrl, parsed.linkToken, options.signal)
608
+
609
+ if (linkInfo.status === 'expired') {
610
+ throw new Error('This file link has expired.')
611
+ }
612
+
613
+ if (!linkInfo.clientEncrypted) {
614
+ throw new Error('This link is not an encrypted file.')
615
+ }
616
+
617
+ const meta = normalizeClientEncryptionMeta(linkInfo.clientEncryptionMeta)
618
+ if (!meta) {
619
+ throw new Error('Invalid encryption metadata for this file.')
620
+ }
621
+
622
+ const tokenBytes = importToken(parsed.encryptionToken)
623
+ const noncePrefix = fromBase64Url(meta.noncePrefix)
624
+ const outputName = sanitizeOutputName(options.output ? path.basename(options.output) : linkInfo.originalName)
625
+ const outputPath = path.resolve(options.output || path.join(options.outputDirectory || process.cwd(), outputName))
626
+
627
+ await fs.mkdir(path.dirname(outputPath), { recursive: true })
628
+
629
+ if (linkInfo.storageMode === 'parts') {
630
+ await decryptFromParts({
631
+ baseUrl: parsed.baseUrl,
632
+ linkToken: parsed.linkToken,
633
+ meta,
634
+ tokenBytes,
635
+ noncePrefix,
636
+ outputPath,
637
+ onProgress: options.onProgress,
638
+ signal: options.signal,
639
+ })
640
+ } else {
641
+ const encryptedFileUrl = linkInfo.downloadUrl
642
+ || `${parsed.baseUrl}/f/${encodeURIComponent(parsed.linkToken)}`
643
+
644
+ await decryptFromSingleFile({
645
+ encryptedFileUrl,
646
+ meta,
647
+ tokenBytes,
648
+ noncePrefix,
649
+ outputPath,
650
+ onProgress: options.onProgress,
651
+ signal: options.signal,
652
+ })
653
+ }
654
+
655
+ return buildDecryptResult({
656
+ outputPath,
657
+ originalName: linkInfo.originalName,
658
+ size: linkInfo.size,
659
+ mimeType: linkInfo.mimeType,
660
+ expiresAt: linkInfo.expiresAt,
661
+ linkToken: parsed.linkToken,
662
+ storageMode: linkInfo.storageMode || 'single',
663
+ })
664
+ }