bytifi 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Bytifi CLI
2
2
 
3
- Official command-line tool for encrypting and uploading files to [Bytifi](https://bytifi.com).
3
+ Official command-line tool for encrypting, uploading, and decrypting files with [Bytifi](https://bytifi.com).
4
4
 
5
5
  ## Install
6
6
 
@@ -49,11 +49,61 @@ Prefer the environment variable — keys on the command line can appear in shell
49
49
 
50
50
  ## Usage
51
51
 
52
+ ### Upload
53
+
52
54
  ```bash
53
55
  bytifi upload ./photo.png
54
- bytifi upload ./photo.png --api-key usk_your_api_key_here
55
- bytifi upload ./photo.png --expires 60 --json
56
- bytifi upload ./large.iso -q
56
+ bytifi upload "./my video (1).mp4"
57
+ bytifi upload ./report.pdf --expires 60 --delete-on-download
58
+ bytifi upload ./large.iso --json > upload.json
59
+ bytifi upload ./photo.png -q
60
+ ```
61
+
62
+ Upload accepts **one file at a time**. Quote paths that contain spaces. Avoid shell globs like `**` — your shell may expand them into dozens of paths.
63
+
64
+ ### Decrypt from a link
65
+
66
+ Download and decrypt directly from a share URL or link token. No API key required.
67
+
68
+ ```bash
69
+ bytifi decrypt 'https://bytifi.com/link?link=LINK_ID#token=ENCRYPTION_TOKEN'
70
+ bytifi decrypt LINK_ID --token ENCRYPTION_TOKEN -o ./restored.mp4
71
+ ```
72
+
73
+ ### Decrypt a downloaded encrypted file
74
+
75
+ If you already downloaded the encrypted blob from `/f/LINK_ID` (browser, curl, etc.):
76
+
77
+ ```bash
78
+ # Easiest — use the upload JSON from when you uploaded
79
+ bytifi decrypt ./downloaded-file --upload-json upload.json
80
+
81
+ # Or pass both values manually
82
+ bytifi decrypt "./my video (1).mp4" \
83
+ --link LINK_ID \
84
+ --token ENCRYPTION_TOKEN
85
+ ```
86
+
87
+ #### Link ID vs encryption token
88
+
89
+ Bytifi uses two different values — don't swap them:
90
+
91
+ | Name | Upload JSON field | Example location |
92
+ |------|-------------------|------------------|
93
+ | **Link ID** | `token` | `/f/QeVuslvdaP-okMxG`, `link?link=QeVuslvdaP-okMxG` |
94
+ | **Encryption token** | `encryptionToken` | `#token=2LTlmBrDkO4GJg0...` in `shareUrl` |
95
+
96
+ - `--link` = link ID (short, ~16 chars)
97
+ - `--token` = encryption key (long, ~43 chars)
98
+
99
+ ### Offline decrypt workflow
100
+
101
+ Save metadata when you upload, so you can decrypt after the link expires:
102
+
103
+ ```bash
104
+ bytifi upload ./report.pdf --json > upload.json
105
+ curl -L "$(jq -r .encryptedFile upload.json)" -o report.encrypted
106
+ bytifi decrypt ./report.encrypted --upload-json upload.json -o ./report.pdf
57
107
  ```
58
108
 
59
109
  Without a global install:
@@ -65,7 +115,7 @@ npm exec bytifi -- upload ./photo.png --api-key usk_your_api_key_here
65
115
 
66
116
  Note: with `npm exec`, put `--` before the file path so npm does not swallow `--api-key`.
67
117
 
68
- ### Options
118
+ ### Upload options
69
119
 
70
120
  | Flag | Description |
71
121
  |------|-------------|
@@ -78,20 +128,47 @@ Note: with `npm exec`, put `--` before the file path so npm does not swallow `--
78
128
  | `--mime-type` | Override detected MIME type |
79
129
  | `--base-url` | API base URL (default: `https://bytifi.com`) |
80
130
 
131
+ ### Decrypt options
132
+
133
+ | Flag | Description |
134
+ |------|-------------|
135
+ | `--token` | Encryption key from `#token=...` (`encryptionToken` in upload JSON) |
136
+ | `--link` | Link ID from upload JSON `token` field (`/f/TOKEN`) |
137
+ | `--upload-json` | Upload `--json` output file (recommended for downloaded files) |
138
+ | `--meta` | Saved `clientEncryptionMeta` JSON for offline decrypt |
139
+ | `--share-url` | Share URL to read token/metadata while decrypting a local file |
140
+ | `-o, --output` | Output file path |
141
+ | `--output-dir` | Output directory when saving under the original filename |
142
+ | `--json` | Machine-readable JSON output |
143
+ | `-q, --quiet` | Print only the output file path |
144
+ | `--verbose` | Print error details to stderr |
145
+ | `--base-url` | API base URL (default: `https://bytifi.com`) |
146
+
81
147
  Exit codes: `0` success, `1` usage error, `2` API error, `3` network error.
82
148
 
83
- JSON output (`--json`) includes `shareUrl`, `downloadUrl`, `encryptionToken`, `expiresAt`, and `token`.
149
+ JSON output (`--json`) for upload includes `shareUrl`, `encryptedFile`, `encryptionToken`, `clientEncryptionMeta`, `expiresAt`, and `token`.
150
+
151
+ JSON output for decrypt includes `outputPath`, `originalName`, `size`, `mimeType`, `expiresAt`, `linkToken`, `storageMode`, and `sourcePath` (for local decrypt).
84
152
 
85
153
  Files over ~100 MB encrypted use multipart upload automatically. Progress prints to stderr unless `--json` or `--quiet` is set.
86
154
 
87
155
  ## How it works
88
156
 
157
+ **Upload**
158
+
89
159
  1. Encrypts the file locally with AES-GCM (same format as the website)
90
160
  2. Uploads encrypted bytes via the public API (parallel part uploads for large files)
91
161
  3. Prints a share URL including `#token=...`
92
162
 
163
+ **Decrypt**
164
+
165
+ 1. Reads link metadata from `/api/link/:token`, from `--upload-json`, or from `--meta`
166
+ 2. Downloads the encrypted file (unless you pass a local encrypted file)
167
+ 3. Decrypts locally and writes the original file to disk
168
+
93
169
  ## Development
94
170
 
95
171
  ```bash
96
- node bin/bytifi.js upload ./file.png --json
172
+ node bin/bytifi.js upload ./file.png --json > upload.json
173
+ node bin/bytifi.js decrypt ./file.encrypted --upload-json upload.json
97
174
  ```
package/bin/bytifi.js CHANGED
@@ -4,18 +4,21 @@ import fs from 'node:fs/promises'
4
4
  import path from 'node:path'
5
5
  import process from 'node:process'
6
6
  import { createRequire } from 'node:module'
7
+ import { decryptFile } from '../lib/decrypt.js'
7
8
  import { BytifiApiError, BytifiNetworkError, uploadFile } from '../lib/upload.js'
8
9
 
9
10
  const require = createRequire(import.meta.url)
10
11
  const { version } = require('../package.json')
11
12
 
12
13
  function printHelp() {
13
- process.stdout.write(`Bytifi CLI v${version} — encrypt and upload files
14
+ process.stdout.write(`Bytifi CLI v${version} — encrypt, upload, and decrypt files
14
15
 
15
16
  Usage:
16
17
  bytifi upload <file> [options]
18
+ bytifi decrypt <url-or-token> [options]
19
+ bytifi decrypt <encrypted-file> [options]
17
20
 
18
- Options:
21
+ Upload options:
19
22
  -k, --api-key <key> API key (default: BYTIFI_API_KEY env var)
20
23
  -e, --expires <minutes> Link lifetime: 5|15|30|60|120 (default: 30)
21
24
  --delete-on-download Remove file after first download
@@ -24,6 +27,21 @@ Options:
24
27
  --verbose Show API error details on stderr
25
28
  --mime-type <type> Override detected MIME type
26
29
  --base-url <url> API base URL (default: https://bytifi.com)
30
+
31
+ Decrypt options:
32
+ --token <token> Encryption key from #token=... (not the link ID)
33
+ --link <token> Link ID from upload JSON "token" field (/f/TOKEN)
34
+ --upload-json <path> Upload --json output (easiest for downloaded files)
35
+ --meta <path> Saved clientEncryptionMeta JSON (offline decrypt)
36
+ --share-url <url> Share URL for token/metadata when decrypting a local file
37
+ -o, --output <path> Output file path (default: original filename)
38
+ --output-dir <dir> Directory for decrypted file (default: cwd)
39
+ --json Print machine-readable JSON to stdout
40
+ -q, --quiet Print only the output file path
41
+ --verbose Show error details on stderr
42
+ --base-url <url> API base URL (default: https://bytifi.com)
43
+
44
+ Global:
27
45
  -V, --version Show version
28
46
  -h, --help Show this help
29
47
 
@@ -35,7 +53,14 @@ Exit codes:
35
53
 
36
54
  Examples:
37
55
  bytifi upload ./photo.png
38
- BYTIFI_API_KEY=usk_... bytifi upload report.pdf --expires 60 --json
56
+ bytifi upload ./video.mp4 --expires 60 --json > upload.json
57
+ bytifi upload ./large.iso -q
58
+
59
+ bytifi decrypt 'https://bytifi.com/link?link=abc#token=...'
60
+ bytifi decrypt abc --token 'ENCRYPTION_TOKEN' -o ./restored.mp4
61
+
62
+ bytifi decrypt ./downloaded.encrypted --upload-json upload.json
63
+ bytifi decrypt "./my file(1).mp4" --link abc --token 'ENCRYPTION_TOKEN'
39
64
  `)
40
65
  }
41
66
 
@@ -47,7 +72,7 @@ function readFlagValue(argv, index, flagName) {
47
72
  return value
48
73
  }
49
74
 
50
- function parseArgs(argv) {
75
+ function parseUploadArgs(argv) {
51
76
  const positional = []
52
77
  const options = {
53
78
  apiKey: process.env.BYTIFI_API_KEY || '',
@@ -134,6 +159,110 @@ function parseArgs(argv) {
134
159
  return { positional, options }
135
160
  }
136
161
 
162
+ function parseDecryptArgs(argv) {
163
+ const positional = []
164
+ const options = {
165
+ encryptionToken: '',
166
+ linkToken: '',
167
+ metaPath: '',
168
+ uploadJsonPath: '',
169
+ shareUrl: '',
170
+ output: '',
171
+ outputDirectory: '',
172
+ json: false,
173
+ quiet: false,
174
+ verbose: false,
175
+ baseUrl: 'https://bytifi.com',
176
+ help: false,
177
+ version: false,
178
+ }
179
+
180
+ for (let index = 0; index < argv.length; index += 1) {
181
+ const arg = argv[index]
182
+
183
+ if (arg === '--help' || arg === '-h') {
184
+ options.help = true
185
+ continue
186
+ }
187
+
188
+ if (arg === '--version' || arg === '-V') {
189
+ options.version = true
190
+ continue
191
+ }
192
+
193
+ if (arg === '--json') {
194
+ options.json = true
195
+ continue
196
+ }
197
+
198
+ if (arg === '--quiet' || arg === '-q') {
199
+ options.quiet = true
200
+ continue
201
+ }
202
+
203
+ if (arg === '--verbose') {
204
+ options.verbose = true
205
+ continue
206
+ }
207
+
208
+ if (arg === '--token') {
209
+ options.encryptionToken = readFlagValue(argv, index, arg)
210
+ index += 1
211
+ continue
212
+ }
213
+
214
+ if (arg === '--link') {
215
+ options.linkToken = readFlagValue(argv, index, arg)
216
+ index += 1
217
+ continue
218
+ }
219
+
220
+ if (arg === '--meta') {
221
+ options.metaPath = readFlagValue(argv, index, arg)
222
+ index += 1
223
+ continue
224
+ }
225
+
226
+ if (arg === '--upload-json') {
227
+ options.uploadJsonPath = readFlagValue(argv, index, arg)
228
+ index += 1
229
+ continue
230
+ }
231
+
232
+ if (arg === '--share-url') {
233
+ options.shareUrl = readFlagValue(argv, index, arg)
234
+ index += 1
235
+ continue
236
+ }
237
+
238
+ if (arg === '--output' || arg === '-o') {
239
+ options.output = readFlagValue(argv, index, arg)
240
+ index += 1
241
+ continue
242
+ }
243
+
244
+ if (arg === '--output-dir') {
245
+ options.outputDirectory = readFlagValue(argv, index, arg)
246
+ index += 1
247
+ continue
248
+ }
249
+
250
+ if (arg === '--base-url') {
251
+ options.baseUrl = readFlagValue(argv, index, arg)
252
+ index += 1
253
+ continue
254
+ }
255
+
256
+ if (arg.startsWith('-')) {
257
+ throw new Error(`Unknown option: ${arg}`)
258
+ }
259
+
260
+ positional.push(arg)
261
+ }
262
+
263
+ return { positional, options }
264
+ }
265
+
137
266
  function validateExpires(minutes) {
138
267
  const allowed = new Set([5, 15, 30, 60, 120])
139
268
  if (!allowed.has(minutes)) {
@@ -141,8 +270,8 @@ function validateExpires(minutes) {
141
270
  }
142
271
  }
143
272
 
144
- function writeProgress(percent) {
145
- process.stderr.write(`\rEncrypting and uploading: ${percent}%`)
273
+ function writeProgress(label, percent) {
274
+ process.stderr.write(`\r${label}: ${percent}%`)
146
275
  }
147
276
 
148
277
  function clearProgressLine() {
@@ -188,7 +317,7 @@ async function runUpload(filePath, options) {
188
317
  ? (percent) => {
189
318
  if (percent !== lastPercent) {
190
319
  lastPercent = percent
191
- writeProgress(percent)
320
+ writeProgress('Encrypting and uploading', percent)
192
321
  }
193
322
  }
194
323
  : undefined,
@@ -209,7 +338,64 @@ async function runUpload(filePath, options) {
209
338
  }
210
339
 
211
340
  process.stdout.write(`Share URL:\n${result.shareUrl}\n`)
212
- process.stdout.write(`Download URL:\n${result.downloadUrl}\n`)
341
+ process.stdout.write(`Encrypted file:\n${result.encryptedFile}\n`)
342
+ process.stdout.write(`Expires: ${result.expiresAt}\n`)
343
+ } finally {
344
+ process.off('SIGINT', handleSignal)
345
+ process.off('SIGTERM', handleSignal)
346
+ }
347
+ }
348
+
349
+ async function runDecrypt(input, options) {
350
+ const abortController = new AbortController()
351
+
352
+ const handleSignal = () => {
353
+ abortController.abort()
354
+ }
355
+
356
+ process.on('SIGINT', handleSignal)
357
+ process.on('SIGTERM', handleSignal)
358
+
359
+ const showProgress = !options.quiet && !options.json
360
+ let lastPercent = -1
361
+
362
+ try {
363
+ const result = await decryptFile(input, {
364
+ encryptionToken: options.encryptionToken,
365
+ linkToken: options.linkToken,
366
+ metaPath: options.metaPath,
367
+ uploadJsonPath: options.uploadJsonPath,
368
+ shareUrl: options.shareUrl,
369
+ output: options.output || undefined,
370
+ outputDirectory: options.outputDirectory || undefined,
371
+ baseUrl: options.baseUrl,
372
+ signal: abortController.signal,
373
+ onProgress: showProgress
374
+ ? (percent) => {
375
+ if (percent !== lastPercent) {
376
+ lastPercent = percent
377
+ writeProgress('Downloading and decrypting', percent)
378
+ }
379
+ }
380
+ : undefined,
381
+ })
382
+
383
+ if (showProgress) {
384
+ clearProgressLine()
385
+ }
386
+
387
+ if (options.json) {
388
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
389
+ return
390
+ }
391
+
392
+ if (options.quiet) {
393
+ process.stdout.write(`${result.outputPath}\n`)
394
+ return
395
+ }
396
+
397
+ process.stdout.write(`Saved: ${result.outputPath}\n`)
398
+ process.stdout.write(`Original name: ${result.originalName}\n`)
213
399
  process.stdout.write(`Expires: ${result.expiresAt}\n`)
214
400
  } finally {
215
401
  process.off('SIGINT', handleSignal)
@@ -224,7 +410,7 @@ function exitCodeForError(error) {
224
410
  }
225
411
 
226
412
  function printError(error, verbose) {
227
- process.stderr.write(`${error.message || 'Upload failed.'}\n`)
413
+ process.stderr.write(`${error.message || 'Command failed.'}\n`)
228
414
 
229
415
  if (!verbose) return
230
416
 
@@ -255,41 +441,74 @@ async function main() {
255
441
  process.exit(0)
256
442
  }
257
443
 
258
- if (command !== 'upload') {
259
- if (command === 'help') {
444
+ if (command === 'help') {
445
+ printHelp()
446
+ process.exit(0)
447
+ }
448
+
449
+ if (command === 'upload') {
450
+ const { positional, options } = parseUploadArgs(rest)
451
+
452
+ if (options.help) {
260
453
  printHelp()
261
454
  process.exit(0)
262
455
  }
263
456
 
264
- throw new Error(`Unknown command: ${command}`)
265
- }
457
+ if (options.version) {
458
+ process.stdout.write(`${version}\n`)
459
+ process.exit(0)
460
+ }
266
461
 
267
- const { positional, options } = parseArgs(rest)
462
+ if (options.json && options.quiet) {
463
+ throw new Error('Use either --json or --quiet, not both.')
464
+ }
268
465
 
269
- if (options.help) {
270
- printHelp()
271
- process.exit(0)
272
- }
466
+ const filePath = positional[0]
467
+ if (!filePath) {
468
+ throw new Error('Missing file path. Usage: bytifi upload <file>')
469
+ }
273
470
 
274
- if (options.version) {
275
- process.stdout.write(`${version}\n`)
276
- process.exit(0)
277
- }
471
+ if (positional.length > 1) {
472
+ throw new Error(
473
+ `Upload accepts one file at a time (got ${positional.length}). Quote paths with spaces and avoid shell globs like **.`,
474
+ )
475
+ }
278
476
 
279
- if (options.json && options.quiet) {
280
- throw new Error('Use either --json or --quiet, not both.')
477
+ await runUpload(filePath, options)
478
+ return
281
479
  }
282
480
 
283
- const filePath = positional[0]
284
- if (!filePath) {
285
- throw new Error('Missing file path. Usage: bytifi upload <file>')
286
- }
481
+ if (command === 'decrypt') {
482
+ const { positional, options } = parseDecryptArgs(rest)
483
+
484
+ if (options.help) {
485
+ printHelp()
486
+ process.exit(0)
487
+ }
488
+
489
+ if (options.version) {
490
+ process.stdout.write(`${version}\n`)
491
+ process.exit(0)
492
+ }
493
+
494
+ if (options.json && options.quiet) {
495
+ throw new Error('Use either --json or --quiet, not both.')
496
+ }
497
+
498
+ const input = positional[0]
499
+ if (!input) {
500
+ throw new Error('Missing input. Usage: bytifi decrypt <url-or-token|encrypted-file>')
501
+ }
502
+
503
+ if (positional.length > 1) {
504
+ process.stderr.write(`Warning: ignoring extra arguments: ${positional.slice(1).join(', ')}\n`)
505
+ }
287
506
 
288
- if (positional.length > 1) {
289
- process.stderr.write(`Warning: ignoring extra files: ${positional.slice(1).join(', ')}\n`)
507
+ await runDecrypt(input, options)
508
+ return
290
509
  }
291
510
 
292
- await runUpload(filePath, options)
511
+ throw new Error(`Unknown command: ${command}`)
293
512
  }
294
513
 
295
514
  main().catch((error) => {
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,765 @@
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(
96
+ 'Missing encryption token. Pass --token with the `#token=...` value from the share URL '
97
+ + '(stored as `encryptionToken` in upload JSON). This is not the same as the link ID.',
98
+ )
99
+ }
100
+
101
+ return {
102
+ baseUrl: resolvedBaseUrl,
103
+ linkToken,
104
+ encryptionToken: resolvedEncryptionToken,
105
+ }
106
+ }
107
+
108
+ export function parseShareReference(input, { encryptionToken = '', baseUrl = DEFAULT_BASE_URL } = {}) {
109
+ const trimmed = String(input || '').trim()
110
+ if (!trimmed) {
111
+ return {
112
+ baseUrl: normalizeBaseUrl(baseUrl),
113
+ linkToken: '',
114
+ encryptionToken: String(encryptionToken || '').trim(),
115
+ }
116
+ }
117
+
118
+ if (!/^https?:\/\//i.test(trimmed) && !trimmed.startsWith('/')) {
119
+ return {
120
+ baseUrl: normalizeBaseUrl(baseUrl),
121
+ linkToken: trimmed,
122
+ encryptionToken: String(encryptionToken || '').trim(),
123
+ }
124
+ }
125
+
126
+ const url = new URL(trimmed, `${normalizeBaseUrl(baseUrl)}/`)
127
+ const hashParams = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash)
128
+
129
+ return {
130
+ baseUrl: `${url.protocol}//${url.host}`,
131
+ linkToken: url.searchParams.get('link') || url.pathname.match(/^\/f\/([^/]+)/)?.[1] || '',
132
+ encryptionToken: String(encryptionToken || hashParams.get('token') || '').trim(),
133
+ }
134
+ }
135
+
136
+ function looksLikeRemoteInput(input) {
137
+ const trimmed = String(input || '').trim()
138
+ return /^https?:\/\//i.test(trimmed)
139
+ || trimmed.startsWith('/link')
140
+ || trimmed.startsWith('/f/')
141
+ }
142
+
143
+ async function pathExists(inputPath) {
144
+ try {
145
+ await fs.access(path.resolve(inputPath))
146
+ return true
147
+ } catch {
148
+ return false
149
+ }
150
+ }
151
+
152
+ async function readMetaFile(metaPath) {
153
+ const raw = await fs.readFile(path.resolve(metaPath), 'utf8')
154
+ const parsed = JSON.parse(raw)
155
+ const meta = normalizeClientEncryptionMeta(parsed?.clientEncryptionMeta || parsed)
156
+
157
+ if (!meta) {
158
+ throw new Error('Invalid encryption metadata file.')
159
+ }
160
+
161
+ return meta
162
+ }
163
+
164
+ async function resolveEncryptionMeta({
165
+ metaPath = '',
166
+ inlineMeta = null,
167
+ linkToken = '',
168
+ baseUrl = DEFAULT_BASE_URL,
169
+ signal,
170
+ }) {
171
+ if (inlineMeta) {
172
+ return { meta: inlineMeta }
173
+ }
174
+
175
+ if (metaPath) {
176
+ return { meta: await readMetaFile(metaPath) }
177
+ }
178
+
179
+ if (!linkToken) {
180
+ throw new Error(
181
+ 'Missing link metadata for a downloaded file. Pass --upload-json upload.json, --link LINK_ID, or --meta meta.json.\n'
182
+ + 'The link ID is the `token` field in upload JSON (also appears as /f/TOKEN and link?link=TOKEN).',
183
+ )
184
+ }
185
+
186
+ const linkInfo = await fetchLinkInfo(baseUrl, linkToken, signal)
187
+
188
+ if (linkInfo.status === 'expired') {
189
+ throw new Error('This file link has expired.')
190
+ }
191
+
192
+ if (!linkInfo.clientEncrypted) {
193
+ throw new Error('This link is not an encrypted file.')
194
+ }
195
+
196
+ const meta = normalizeClientEncryptionMeta(linkInfo.clientEncryptionMeta)
197
+ if (!meta) {
198
+ throw new Error('Invalid encryption metadata for this file.')
199
+ }
200
+
201
+ return { meta, linkInfo }
202
+ }
203
+
204
+ function sanitizeOutputName(filename) {
205
+ const base = path.basename(String(filename || 'download').replace(/[\0\r\n]/g, ''))
206
+ return base || 'download'
207
+ }
208
+
209
+ async function fetchLinkInfo(baseUrl, linkToken, signal) {
210
+ return publicFetch(baseUrl, `/api/link/${encodeURIComponent(linkToken)}`, { signal })
211
+ }
212
+
213
+ async function fetchEncryptedPart(baseUrl, linkToken, partNumber, signal) {
214
+ const response = await fetch(
215
+ `${normalizeBaseUrl(baseUrl)}/f/${encodeURIComponent(linkToken)}/p/${partNumber}`,
216
+ { signal },
217
+ )
218
+
219
+ if (!response.ok) {
220
+ const text = await response.text()
221
+ throw new BytifiApiError(text || `Failed to download part ${partNumber}.`, {
222
+ status: response.status,
223
+ body: text,
224
+ })
225
+ }
226
+
227
+ return Buffer.from(await response.arrayBuffer())
228
+ }
229
+
230
+ async function decryptFromParts({
231
+ baseUrl,
232
+ linkToken,
233
+ meta,
234
+ tokenBytes,
235
+ noncePrefix,
236
+ outputPath,
237
+ onProgress,
238
+ signal,
239
+ }) {
240
+ const { chunks } = buildEncryptedChunkPlan(meta)
241
+ const fileHandle = await fs.open(outputPath, 'w')
242
+ let decryptedBytes = 0
243
+
244
+ try {
245
+ for (let index = 0; index < chunks.length; index += 1) {
246
+ if (signal?.aborted) {
247
+ throw new Error('Decrypt aborted.')
248
+ }
249
+
250
+ const chunk = chunks[index]
251
+ const encryptedPart = await fetchEncryptedPart(baseUrl, linkToken, chunk.chunkIndex + 1, signal)
252
+ const plainPart = decryptChunk(encryptedPart, tokenBytes, noncePrefix, chunk.chunkIndex)
253
+
254
+ await fileHandle.write(plainPart)
255
+ decryptedBytes += plainPart.length
256
+ onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
257
+ }
258
+ } finally {
259
+ await fileHandle.close()
260
+ }
261
+ }
262
+
263
+ async function decryptFromSingleFile({
264
+ encryptedFileUrl,
265
+ meta,
266
+ tokenBytes,
267
+ noncePrefix,
268
+ outputPath,
269
+ onProgress,
270
+ signal,
271
+ }) {
272
+ const { chunks, totalEncryptedSize } = buildEncryptedChunkPlan(meta)
273
+ const response = await fetch(encryptedFileUrl, { signal })
274
+
275
+ if (!response.ok) {
276
+ const text = await response.text()
277
+ throw new BytifiApiError(text || 'Failed to download encrypted file.', {
278
+ status: response.status,
279
+ body: text,
280
+ })
281
+ }
282
+
283
+ if (!response.body) {
284
+ const encryptedBuffer = Buffer.from(await response.arrayBuffer())
285
+ await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
286
+ return
287
+ }
288
+
289
+ const reader = response.body.getReader()
290
+ const fileHandle = await fs.open(outputPath, 'w')
291
+ const pending = []
292
+ let pendingLength = 0
293
+ let downloadedBytes = 0
294
+ let nextChunkIndex = 0
295
+ let decryptedBytes = 0
296
+
297
+ const takeBytes = (length) => {
298
+ while (pendingLength < length) {
299
+ return null
300
+ }
301
+
302
+ const out = Buffer.alloc(length)
303
+ let offset = 0
304
+
305
+ while (offset < length) {
306
+ const head = pending[0]
307
+ const take = Math.min(length - offset, head.length)
308
+ head.copy(out, offset, 0, take)
309
+ offset += take
310
+
311
+ if (take === head.length) {
312
+ pending.shift()
313
+ } else {
314
+ pending[0] = head.subarray(take)
315
+ }
316
+ pendingLength -= take
317
+ }
318
+
319
+ return out
320
+ }
321
+
322
+ try {
323
+ while (nextChunkIndex < chunks.length) {
324
+ if (signal?.aborted) {
325
+ throw new Error('Decrypt aborted.')
326
+ }
327
+
328
+ const chunk = chunks[nextChunkIndex]
329
+
330
+ while (pendingLength < chunk.encryptedSize) {
331
+ const { done, value } = await reader.read()
332
+ if (done) break
333
+ const buffer = Buffer.from(value)
334
+ pending.push(buffer)
335
+ pendingLength += buffer.length
336
+ downloadedBytes += buffer.length
337
+ onProgress?.(Math.min(99, Math.round((downloadedBytes / totalEncryptedSize) * 100)))
338
+ }
339
+
340
+ const encryptedChunk = takeBytes(chunk.encryptedSize)
341
+ if (!encryptedChunk) {
342
+ throw new Error('Encrypted file ended before all parts were downloaded.')
343
+ }
344
+
345
+ const plainPart = decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunk.chunkIndex)
346
+ await fileHandle.write(plainPart)
347
+ decryptedBytes += plainPart.length
348
+ nextChunkIndex += 1
349
+ onProgress?.(Math.min(99, Math.round((decryptedBytes / meta.originalSize) * 100)))
350
+ }
351
+ } finally {
352
+ await reader.cancel().catch(() => {})
353
+ await fileHandle.close()
354
+ }
355
+
356
+ onProgress?.(100)
357
+ }
358
+
359
+ async function writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal) {
360
+ const fileHandle = await fs.open(outputPath, 'w')
361
+ let offset = 0
362
+ let decryptedBytes = 0
363
+
364
+ try {
365
+ for (const chunk of chunks) {
366
+ if (signal?.aborted) {
367
+ throw new Error('Decrypt aborted.')
368
+ }
369
+
370
+ const encryptedChunk = encryptedBuffer.subarray(offset, offset + chunk.encryptedSize)
371
+ if (encryptedChunk.length !== chunk.encryptedSize) {
372
+ throw new Error('Encrypted file ended before all parts were downloaded.')
373
+ }
374
+
375
+ const plainPart = decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunk.chunkIndex)
376
+ await fileHandle.write(plainPart)
377
+ offset += chunk.encryptedSize
378
+ decryptedBytes += plainPart.length
379
+ onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
380
+ }
381
+ } finally {
382
+ await fileHandle.close()
383
+ }
384
+
385
+ onProgress?.(100)
386
+ }
387
+
388
+ async function decryptFromLocalSingleFile({
389
+ encryptedFilePath,
390
+ meta,
391
+ tokenBytes,
392
+ noncePrefix,
393
+ outputPath,
394
+ onProgress,
395
+ signal,
396
+ }) {
397
+ const { chunks } = buildEncryptedChunkPlan(meta)
398
+ const stat = await fs.stat(encryptedFilePath)
399
+
400
+ if (stat.size <= 64 * 1024 * 1024) {
401
+ const encryptedBuffer = await fs.readFile(encryptedFilePath)
402
+ await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
403
+ return
404
+ }
405
+
406
+ const fileHandle = await fs.open(encryptedFilePath, 'r')
407
+ const outputHandle = await fs.open(outputPath, 'w')
408
+ let offset = 0
409
+ let decryptedBytes = 0
410
+
411
+ try {
412
+ for (const chunk of chunks) {
413
+ if (signal?.aborted) {
414
+ throw new Error('Decrypt aborted.')
415
+ }
416
+
417
+ const encryptedChunk = Buffer.alloc(chunk.encryptedSize)
418
+ const { bytesRead } = await fileHandle.read(encryptedChunk, 0, chunk.encryptedSize, offset)
419
+
420
+ if (bytesRead !== chunk.encryptedSize) {
421
+ throw new Error('Encrypted file ended before all parts were read.')
422
+ }
423
+
424
+ const plainPart = decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunk.chunkIndex)
425
+ await outputHandle.write(plainPart)
426
+ offset += chunk.encryptedSize
427
+ decryptedBytes += plainPart.length
428
+ onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
429
+ }
430
+ } finally {
431
+ await fileHandle.close()
432
+ await outputHandle.close()
433
+ }
434
+
435
+ onProgress?.(100)
436
+ }
437
+
438
+ async function listLocalPartFiles(dirPath) {
439
+ const entries = await fs.readdir(dirPath)
440
+ const parts = entries
441
+ .map((name) => {
442
+ const match = name.match(/^(\d+)(?:\.part)?$/)
443
+ return match ? { partNumber: Number(match[1]), name } : null
444
+ })
445
+ .filter(Boolean)
446
+ .sort((left, right) => left.partNumber - right.partNumber)
447
+
448
+ if (parts.length === 0) {
449
+ throw new Error('No numbered part files found in directory. Expected names like 1, 2, or 1.part, 2.part.')
450
+ }
451
+
452
+ return parts
453
+ }
454
+
455
+ async function decryptFromLocalParts({
456
+ partsDirectory,
457
+ meta,
458
+ tokenBytes,
459
+ noncePrefix,
460
+ outputPath,
461
+ onProgress,
462
+ signal,
463
+ }) {
464
+ const { chunks } = buildEncryptedChunkPlan(meta)
465
+ const parts = await listLocalPartFiles(partsDirectory)
466
+
467
+ if (parts.length !== chunks.length) {
468
+ throw new Error(`Expected ${chunks.length} part files, found ${parts.length}.`)
469
+ }
470
+
471
+ const fileHandle = await fs.open(outputPath, 'w')
472
+ let decryptedBytes = 0
473
+
474
+ try {
475
+ for (const chunk of chunks) {
476
+ if (signal?.aborted) {
477
+ throw new Error('Decrypt aborted.')
478
+ }
479
+
480
+ const partNumber = chunk.chunkIndex + 1
481
+ const partEntry = parts[chunk.chunkIndex]
482
+
483
+ if (!partEntry || partEntry.partNumber !== partNumber) {
484
+ throw new Error(`Missing local part file ${partNumber}.`)
485
+ }
486
+
487
+ const encryptedPart = await fs.readFile(path.join(partsDirectory, partEntry.name))
488
+ const plainPart = decryptChunk(encryptedPart, tokenBytes, noncePrefix, chunk.chunkIndex)
489
+
490
+ await fileHandle.write(plainPart)
491
+ decryptedBytes += plainPart.length
492
+ onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
493
+ }
494
+ } finally {
495
+ await fileHandle.close()
496
+ }
497
+
498
+ onProgress?.(100)
499
+ }
500
+
501
+ function hintTokenConfusion(encryptionToken, linkToken) {
502
+ const token = String(encryptionToken || '').trim()
503
+
504
+ if (!token || linkToken) return
505
+
506
+ if (token.length <= 20) {
507
+ throw new Error(
508
+ `"${token}" looks like a link ID, not an encryption token.\n`
509
+ + 'Use --link for the link ID (`token` in upload JSON) and --token for the encryption key (`encryptionToken`).\n'
510
+ + 'Easiest: bytifi decrypt ./file.encrypted --upload-json upload.json',
511
+ )
512
+ }
513
+ }
514
+
515
+ export async function loadUploadJson(uploadJsonPath) {
516
+ const raw = await fs.readFile(path.resolve(uploadJsonPath), 'utf8')
517
+ let parsed
518
+
519
+ try {
520
+ parsed = JSON.parse(raw)
521
+ } catch {
522
+ throw new Error('Invalid upload JSON file.')
523
+ }
524
+
525
+ const meta = normalizeClientEncryptionMeta(parsed.clientEncryptionMeta)
526
+ if (!meta) {
527
+ throw new Error('Upload JSON is missing clientEncryptionMeta.')
528
+ }
529
+
530
+ const encryptionToken = String(parsed.encryptionToken || '').trim()
531
+ if (!encryptionToken) {
532
+ throw new Error('Upload JSON is missing encryptionToken.')
533
+ }
534
+
535
+ return {
536
+ linkToken: String(parsed.token || '').trim(),
537
+ encryptionToken,
538
+ meta,
539
+ originalName: parsed.originalName || 'download',
540
+ shareUrl: parsed.shareUrl || '',
541
+ expiresAt: parsed.expiresAt || null,
542
+ }
543
+ }
544
+
545
+ async function applyUploadJsonDefaults(options) {
546
+ if (!options.uploadJsonPath) {
547
+ return options
548
+ }
549
+
550
+ const upload = await loadUploadJson(options.uploadJsonPath)
551
+
552
+ return {
553
+ ...options,
554
+ linkToken: options.linkToken || upload.linkToken,
555
+ encryptionToken: options.encryptionToken || upload.encryptionToken,
556
+ inlineMeta: options.inlineMeta || upload.meta,
557
+ originalName: options.originalName || upload.originalName,
558
+ shareUrl: options.shareUrl || upload.shareUrl,
559
+ uploadExpiresAt: upload.expiresAt,
560
+ }
561
+ }
562
+
563
+ function resolveEncryptionToken(encryptionToken, shareReference, linkToken = '') {
564
+ const resolved = String(encryptionToken || shareReference?.encryptionToken || '').trim()
565
+
566
+ hintTokenConfusion(resolved, linkToken)
567
+
568
+ if (!resolved) {
569
+ throw new Error(
570
+ 'Missing encryption token. Pass --token with the `#token=...` value from the share URL '
571
+ + '(stored as `encryptionToken` in upload JSON), or use --upload-json upload.json.',
572
+ )
573
+ }
574
+
575
+ return resolved
576
+ }
577
+
578
+ function buildDecryptResult({
579
+ outputPath,
580
+ originalName,
581
+ size,
582
+ mimeType,
583
+ expiresAt = null,
584
+ linkToken = '',
585
+ storageMode = 'single',
586
+ sourcePath = '',
587
+ }) {
588
+ return {
589
+ outputPath,
590
+ originalName,
591
+ size,
592
+ mimeType,
593
+ expiresAt,
594
+ linkToken,
595
+ storageMode,
596
+ sourcePath,
597
+ }
598
+ }
599
+
600
+ async function decryptLocalFile(inputPath, options = {}) {
601
+ const resolvedOptions = await applyUploadJsonDefaults(options)
602
+ const absolutePath = path.resolve(inputPath)
603
+ const stat = await fs.stat(absolutePath)
604
+ const shareReference = parseShareReference(resolvedOptions.shareUrl || '', {
605
+ encryptionToken: resolvedOptions.encryptionToken,
606
+ baseUrl: resolvedOptions.baseUrl,
607
+ })
608
+ const linkToken = resolvedOptions.linkToken || shareReference.linkToken
609
+
610
+ if (
611
+ !resolvedOptions.uploadJsonPath
612
+ && !resolvedOptions.metaPath
613
+ && !resolvedOptions.inlineMeta
614
+ && !linkToken
615
+ ) {
616
+ hintTokenConfusion(resolvedOptions.encryptionToken, '')
617
+ }
618
+
619
+ const resolved = await resolveEncryptionMeta({
620
+ metaPath: resolvedOptions.metaPath,
621
+ inlineMeta: resolvedOptions.inlineMeta,
622
+ linkToken,
623
+ baseUrl: resolvedOptions.baseUrl || shareReference.baseUrl,
624
+ signal: resolvedOptions.signal,
625
+ })
626
+ const meta = resolved.meta || resolved
627
+ const linkInfo = resolved.linkInfo || null
628
+ const encryptionToken = resolveEncryptionToken(
629
+ resolvedOptions.encryptionToken,
630
+ shareReference,
631
+ linkToken,
632
+ )
633
+ const tokenBytes = importToken(encryptionToken)
634
+ const noncePrefix = fromBase64Url(meta.noncePrefix)
635
+ const originalName = linkInfo?.originalName || resolvedOptions.originalName || 'download'
636
+ const outputName = sanitizeOutputName(resolvedOptions.output ? path.basename(resolvedOptions.output) : originalName)
637
+ const outputPath = path.resolve(
638
+ resolvedOptions.output || path.join(resolvedOptions.outputDirectory || process.cwd(), outputName),
639
+ )
640
+
641
+ await fs.mkdir(path.dirname(outputPath), { recursive: true })
642
+
643
+ if (stat.isDirectory()) {
644
+ await decryptFromLocalParts({
645
+ partsDirectory: absolutePath,
646
+ meta,
647
+ tokenBytes,
648
+ noncePrefix,
649
+ outputPath,
650
+ onProgress: resolvedOptions.onProgress,
651
+ signal: resolvedOptions.signal,
652
+ })
653
+
654
+ return buildDecryptResult({
655
+ outputPath,
656
+ originalName,
657
+ size: meta.originalSize,
658
+ mimeType: meta.mimeType,
659
+ expiresAt: linkInfo?.expiresAt || resolvedOptions.uploadExpiresAt || null,
660
+ linkToken,
661
+ storageMode: 'parts',
662
+ sourcePath: absolutePath,
663
+ })
664
+ }
665
+
666
+ await decryptFromLocalSingleFile({
667
+ encryptedFilePath: absolutePath,
668
+ meta,
669
+ tokenBytes,
670
+ noncePrefix,
671
+ outputPath,
672
+ onProgress: resolvedOptions.onProgress,
673
+ signal: resolvedOptions.signal,
674
+ })
675
+
676
+ return buildDecryptResult({
677
+ outputPath,
678
+ originalName,
679
+ size: meta.originalSize,
680
+ mimeType: meta.mimeType,
681
+ expiresAt: linkInfo?.expiresAt || resolvedOptions.uploadExpiresAt || null,
682
+ linkToken,
683
+ storageMode: 'single',
684
+ sourcePath: absolutePath,
685
+ })
686
+ }
687
+
688
+ export async function decryptFile(input, options = {}) {
689
+ const resolvedOptions = await applyUploadJsonDefaults(options)
690
+ const trimmedInput = String(input || '').trim()
691
+ if (!trimmedInput) {
692
+ throw new Error('Missing share URL, link token, or encrypted file path.')
693
+ }
694
+
695
+ if (resolvedOptions.localFile || (!looksLikeRemoteInput(trimmedInput) && await pathExists(trimmedInput))) {
696
+ return decryptLocalFile(trimmedInput, resolvedOptions)
697
+ }
698
+
699
+ const parsed = parseDecryptInput(trimmedInput, {
700
+ encryptionToken: resolvedOptions.encryptionToken,
701
+ baseUrl: resolvedOptions.baseUrl,
702
+ })
703
+
704
+ const linkInfo = await fetchLinkInfo(parsed.baseUrl, parsed.linkToken, resolvedOptions.signal)
705
+
706
+ if (linkInfo.status === 'expired') {
707
+ throw new Error('This file link has expired.')
708
+ }
709
+
710
+ if (!linkInfo.clientEncrypted) {
711
+ throw new Error('This link is not an encrypted file.')
712
+ }
713
+
714
+ const meta = normalizeClientEncryptionMeta(linkInfo.clientEncryptionMeta)
715
+ if (!meta) {
716
+ throw new Error('Invalid encryption metadata for this file.')
717
+ }
718
+
719
+ const tokenBytes = importToken(parsed.encryptionToken)
720
+ const noncePrefix = fromBase64Url(meta.noncePrefix)
721
+ const outputName = sanitizeOutputName(
722
+ resolvedOptions.output ? path.basename(resolvedOptions.output) : linkInfo.originalName,
723
+ )
724
+ const outputPath = path.resolve(
725
+ resolvedOptions.output || path.join(resolvedOptions.outputDirectory || process.cwd(), outputName),
726
+ )
727
+
728
+ await fs.mkdir(path.dirname(outputPath), { recursive: true })
729
+
730
+ if (linkInfo.storageMode === 'parts') {
731
+ await decryptFromParts({
732
+ baseUrl: parsed.baseUrl,
733
+ linkToken: parsed.linkToken,
734
+ meta,
735
+ tokenBytes,
736
+ noncePrefix,
737
+ outputPath,
738
+ onProgress: resolvedOptions.onProgress,
739
+ signal: resolvedOptions.signal,
740
+ })
741
+ } else {
742
+ const encryptedFileUrl = linkInfo.downloadUrl
743
+ || `${parsed.baseUrl}/f/${encodeURIComponent(parsed.linkToken)}`
744
+
745
+ await decryptFromSingleFile({
746
+ encryptedFileUrl,
747
+ meta,
748
+ tokenBytes,
749
+ noncePrefix,
750
+ outputPath,
751
+ onProgress: resolvedOptions.onProgress,
752
+ signal: resolvedOptions.signal,
753
+ })
754
+ }
755
+
756
+ return buildDecryptResult({
757
+ outputPath,
758
+ originalName: linkInfo.originalName,
759
+ size: linkInfo.size,
760
+ mimeType: linkInfo.mimeType,
761
+ expiresAt: linkInfo.expiresAt,
762
+ linkToken: parsed.linkToken,
763
+ storageMode: linkInfo.storageMode || 'single',
764
+ })
765
+ }
package/lib/upload.js CHANGED
@@ -123,9 +123,10 @@ function buildResult(payload, context, shareUrl) {
123
123
  return {
124
124
  shareUrl,
125
125
  url: payload.url,
126
- downloadUrl: payload.downloadUrl,
126
+ encryptedFile: payload.downloadUrl,
127
127
  token: payload.token,
128
128
  encryptionToken: context.token,
129
+ clientEncryptionMeta: context.meta,
129
130
  originalName: payload.originalName || context.originalName,
130
131
  size: payload.size,
131
132
  expiresAt: payload.expiresAt,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bytifi",
3
- "version": "0.1.2",
4
- "description": "Official Bytifi CLI — encrypt and upload files from the terminal",
3
+ "version": "0.1.4",
4
+ "description": "Official Bytifi CLI — encrypt, upload, and decrypt files from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "bytifi": "./bin/bytifi.js"
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "scripts": {
17
17
  "upload": "node bin/bytifi.js upload",
18
+ "decrypt": "node bin/bytifi.js decrypt",
18
19
  "build:bundle": "esbuild bin/bytifi.js --bundle --platform=node --format=cjs --outfile=dist/bytifi-bundle.cjs",
19
20
  "build:win": "npm run build:bundle && pkg dist/bytifi-bundle.cjs --targets node22-win-x64 --output dist/bytifi.exe --compress GZip"
20
21
  },