bytifi 0.1.1 → 0.1.2
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 +33 -10
- package/bin/bytifi.js +149 -38
- package/lib/upload.js +116 -17
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -4,13 +4,15 @@ Official command-line tool for encrypting and uploading files to [Bytifi](https:
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
+
### npm (all platforms)
|
|
8
|
+
|
|
7
9
|
```bash
|
|
8
10
|
npm install -g bytifi
|
|
9
11
|
```
|
|
10
12
|
|
|
11
13
|
Requires **Node.js 18+**.
|
|
12
14
|
|
|
13
|
-
Or
|
|
15
|
+
Or from source:
|
|
14
16
|
|
|
15
17
|
```bash
|
|
16
18
|
git clone https://github.com/jpwcguy/Bytifi.git
|
|
@@ -18,14 +20,33 @@ cd Bytifi
|
|
|
18
20
|
npm link
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
### Windows (WinGet)
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
winget install Bytifi.Bytifi
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or download [bytifi.exe from GitHub Releases](https://github.com/jpwcguy/Bytifi/releases/latest).
|
|
30
|
+
|
|
31
|
+
### Environment variable (all platforms)
|
|
22
32
|
|
|
23
33
|
```bash
|
|
24
34
|
export BYTIFI_API_KEY=usk_your_api_key_here
|
|
25
35
|
```
|
|
26
36
|
|
|
37
|
+
PowerShell:
|
|
38
|
+
|
|
39
|
+
```powershell
|
|
40
|
+
$env:BYTIFI_API_KEY = "usk_your_api_key_here"
|
|
41
|
+
```
|
|
42
|
+
|
|
27
43
|
Create an API key in **Account → API** on bytifi.com.
|
|
28
44
|
|
|
45
|
+
## Setup
|
|
46
|
+
|
|
47
|
+
Set your API key via environment variable (see Install above) or pass `--api-key` per command.
|
|
48
|
+
Prefer the environment variable — keys on the command line can appear in shell history and process lists.
|
|
49
|
+
|
|
29
50
|
## Usage
|
|
30
51
|
|
|
31
52
|
```bash
|
|
@@ -53,22 +74,24 @@ Note: with `npm exec`, put `--` before the file path so npm does not swallow `--
|
|
|
53
74
|
| `--delete-on-download` | Delete after first download |
|
|
54
75
|
| `--json` | Machine-readable JSON output |
|
|
55
76
|
| `-q, --quiet` | Print only the share URL |
|
|
56
|
-
| `--
|
|
77
|
+
| `--verbose` | Print API error details to stderr |
|
|
78
|
+
| `--mime-type` | Override detected MIME type |
|
|
79
|
+
| `--base-url` | API base URL (default: `https://bytifi.com`) |
|
|
80
|
+
|
|
81
|
+
Exit codes: `0` success, `1` usage error, `2` API error, `3` network error.
|
|
82
|
+
|
|
83
|
+
JSON output (`--json`) includes `shareUrl`, `downloadUrl`, `encryptionToken`, `expiresAt`, and `token`.
|
|
84
|
+
|
|
85
|
+
Files over ~100 MB encrypted use multipart upload automatically. Progress prints to stderr unless `--json` or `--quiet` is set.
|
|
57
86
|
|
|
58
87
|
## How it works
|
|
59
88
|
|
|
60
89
|
1. Encrypts the file locally with AES-GCM (same format as the website)
|
|
61
|
-
2. Uploads encrypted bytes via the public API
|
|
90
|
+
2. Uploads encrypted bytes via the public API (parallel part uploads for large files)
|
|
62
91
|
3. Prints a share URL including `#token=...`
|
|
63
92
|
|
|
64
|
-
The server never receives plaintext or the decryption token.
|
|
65
|
-
|
|
66
93
|
## Development
|
|
67
94
|
|
|
68
95
|
```bash
|
|
69
96
|
node bin/bytifi.js upload ./file.png --json
|
|
70
97
|
```
|
|
71
|
-
|
|
72
|
-
## Status
|
|
73
|
-
|
|
74
|
-
WIP — v0.1.0 direct + multipart upload supported.
|
package/bin/bytifi.js
CHANGED
|
@@ -3,33 +3,50 @@
|
|
|
3
3
|
import fs from 'node:fs/promises'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import process from 'node:process'
|
|
6
|
-
import {
|
|
6
|
+
import { createRequire } from 'node:module'
|
|
7
7
|
import { BytifiApiError, BytifiNetworkError, uploadFile } from '../lib/upload.js'
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const require = createRequire(import.meta.url)
|
|
10
|
+
const { version } = require('../package.json')
|
|
10
11
|
|
|
11
12
|
function printHelp() {
|
|
12
|
-
process.stdout.write(`Bytifi CLI — encrypt and upload files
|
|
13
|
+
process.stdout.write(`Bytifi CLI v${version} — encrypt and upload files
|
|
13
14
|
|
|
14
15
|
Usage:
|
|
15
16
|
bytifi upload <file> [options]
|
|
16
17
|
|
|
17
18
|
Options:
|
|
18
|
-
-k, --api-key <key>
|
|
19
|
-
-e, --expires <minutes>
|
|
19
|
+
-k, --api-key <key> API key (default: BYTIFI_API_KEY env var)
|
|
20
|
+
-e, --expires <minutes> Link lifetime: 5|15|30|60|120 (default: 30)
|
|
20
21
|
--delete-on-download Remove file after first download
|
|
21
22
|
--json Print machine-readable JSON to stdout
|
|
22
23
|
-q, --quiet Print only the share URL
|
|
24
|
+
--verbose Show API error details on stderr
|
|
23
25
|
--mime-type <type> Override detected MIME type
|
|
24
26
|
--base-url <url> API base URL (default: https://bytifi.com)
|
|
27
|
+
-V, --version Show version
|
|
25
28
|
-h, --help Show this help
|
|
26
29
|
|
|
30
|
+
Exit codes:
|
|
31
|
+
0 success
|
|
32
|
+
1 usage or validation error
|
|
33
|
+
2 API error (4xx/5xx response)
|
|
34
|
+
3 network error
|
|
35
|
+
|
|
27
36
|
Examples:
|
|
28
37
|
bytifi upload ./photo.png
|
|
29
38
|
BYTIFI_API_KEY=usk_... bytifi upload report.pdf --expires 60 --json
|
|
30
39
|
`)
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
function readFlagValue(argv, index, flagName) {
|
|
43
|
+
const value = argv[index + 1]
|
|
44
|
+
if (!value || value.startsWith('-')) {
|
|
45
|
+
throw new Error(`Option ${flagName} requires a value.`)
|
|
46
|
+
}
|
|
47
|
+
return value
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
function parseArgs(argv) {
|
|
34
51
|
const positional = []
|
|
35
52
|
const options = {
|
|
@@ -38,9 +55,11 @@ function parseArgs(argv) {
|
|
|
38
55
|
deleteOnDownload: false,
|
|
39
56
|
json: false,
|
|
40
57
|
quiet: false,
|
|
58
|
+
verbose: false,
|
|
41
59
|
mimeType: '',
|
|
42
60
|
baseUrl: 'https://bytifi.com',
|
|
43
61
|
help: false,
|
|
62
|
+
version: false,
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -51,6 +70,11 @@ function parseArgs(argv) {
|
|
|
51
70
|
continue
|
|
52
71
|
}
|
|
53
72
|
|
|
73
|
+
if (arg === '--version' || arg === '-V') {
|
|
74
|
+
options.version = true
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
if (arg === '--json') {
|
|
55
79
|
options.json = true
|
|
56
80
|
continue
|
|
@@ -61,31 +85,41 @@ function parseArgs(argv) {
|
|
|
61
85
|
continue
|
|
62
86
|
}
|
|
63
87
|
|
|
88
|
+
if (arg === '--verbose') {
|
|
89
|
+
options.verbose = true
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
64
93
|
if (arg === '--delete-on-download') {
|
|
65
94
|
options.deleteOnDownload = true
|
|
66
95
|
continue
|
|
67
96
|
}
|
|
68
97
|
|
|
69
98
|
if (arg === '--api-key' || arg === '-k') {
|
|
70
|
-
options.apiKey = argv
|
|
99
|
+
options.apiKey = readFlagValue(argv, index, arg)
|
|
71
100
|
index += 1
|
|
72
101
|
continue
|
|
73
102
|
}
|
|
74
103
|
|
|
75
104
|
if (arg === '--expires' || arg === '-e') {
|
|
76
|
-
|
|
105
|
+
const raw = readFlagValue(argv, index, arg)
|
|
106
|
+
const minutes = Number(raw)
|
|
107
|
+
if (!Number.isFinite(minutes)) {
|
|
108
|
+
throw new Error(`Invalid expires value: ${raw}`)
|
|
109
|
+
}
|
|
110
|
+
options.expiresInMinutes = minutes
|
|
77
111
|
index += 1
|
|
78
112
|
continue
|
|
79
113
|
}
|
|
80
114
|
|
|
81
115
|
if (arg === '--mime-type') {
|
|
82
|
-
options.mimeType = argv
|
|
116
|
+
options.mimeType = readFlagValue(argv, index, arg)
|
|
83
117
|
index += 1
|
|
84
118
|
continue
|
|
85
119
|
}
|
|
86
120
|
|
|
87
121
|
if (arg === '--base-url') {
|
|
88
|
-
options.baseUrl = argv
|
|
122
|
+
options.baseUrl = readFlagValue(argv, index, arg)
|
|
89
123
|
index += 1
|
|
90
124
|
continue
|
|
91
125
|
}
|
|
@@ -107,6 +141,14 @@ function validateExpires(minutes) {
|
|
|
107
141
|
}
|
|
108
142
|
}
|
|
109
143
|
|
|
144
|
+
function writeProgress(percent) {
|
|
145
|
+
process.stderr.write(`\rEncrypting and uploading: ${percent}%`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function clearProgressLine() {
|
|
149
|
+
process.stderr.write('\r\x1b[K')
|
|
150
|
+
}
|
|
151
|
+
|
|
110
152
|
async function runUpload(filePath, options) {
|
|
111
153
|
validateExpires(options.expiresInMinutes)
|
|
112
154
|
|
|
@@ -115,34 +157,64 @@ async function runUpload(filePath, options) {
|
|
|
115
157
|
}
|
|
116
158
|
|
|
117
159
|
const resolvedPath = path.resolve(filePath)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await fs.access(resolvedPath)
|
|
163
|
+
} catch {
|
|
164
|
+
throw new Error(`File not found or not readable: ${resolvedPath}`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const abortController = new AbortController()
|
|
168
|
+
|
|
169
|
+
const handleSignal = () => {
|
|
170
|
+
abortController.abort()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
process.on('SIGINT', handleSignal)
|
|
174
|
+
process.on('SIGTERM', handleSignal)
|
|
175
|
+
|
|
176
|
+
const showProgress = !options.quiet && !options.json
|
|
177
|
+
let lastPercent = -1
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const result = await uploadFile(resolvedPath, {
|
|
181
|
+
apiKey: options.apiKey,
|
|
182
|
+
baseUrl: options.baseUrl,
|
|
183
|
+
expiresInMinutes: options.expiresInMinutes,
|
|
184
|
+
deleteOnDownload: options.deleteOnDownload,
|
|
185
|
+
mimeType: options.mimeType || undefined,
|
|
186
|
+
signal: abortController.signal,
|
|
187
|
+
onProgress: showProgress
|
|
188
|
+
? (percent) => {
|
|
189
|
+
if (percent !== lastPercent) {
|
|
190
|
+
lastPercent = percent
|
|
191
|
+
writeProgress(percent)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
: undefined,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
if (showProgress) {
|
|
198
|
+
clearProgressLine()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (options.json) {
|
|
202
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (options.quiet) {
|
|
207
|
+
process.stdout.write(`${result.shareUrl}\n`)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
process.stdout.write(`Share URL:\n${result.shareUrl}\n`)
|
|
212
|
+
process.stdout.write(`Download URL:\n${result.downloadUrl}\n`)
|
|
213
|
+
process.stdout.write(`Expires: ${result.expiresAt}\n`)
|
|
214
|
+
} finally {
|
|
215
|
+
process.off('SIGINT', handleSignal)
|
|
216
|
+
process.off('SIGTERM', handleSignal)
|
|
217
|
+
}
|
|
146
218
|
}
|
|
147
219
|
|
|
148
220
|
function exitCodeForError(error) {
|
|
@@ -151,6 +223,25 @@ function exitCodeForError(error) {
|
|
|
151
223
|
return 1
|
|
152
224
|
}
|
|
153
225
|
|
|
226
|
+
function printError(error, verbose) {
|
|
227
|
+
process.stderr.write(`${error.message || 'Upload failed.'}\n`)
|
|
228
|
+
|
|
229
|
+
if (!verbose) return
|
|
230
|
+
|
|
231
|
+
if (error instanceof BytifiApiError) {
|
|
232
|
+
if (error.status) {
|
|
233
|
+
process.stderr.write(`HTTP ${error.status}\n`)
|
|
234
|
+
}
|
|
235
|
+
if (error.body) {
|
|
236
|
+
process.stderr.write(`${JSON.stringify(error.body, null, 2)}\n`)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (error instanceof BytifiNetworkError && error.cause) {
|
|
241
|
+
process.stderr.write(`${error.cause}\n`)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
154
245
|
async function main() {
|
|
155
246
|
const [command, ...rest] = process.argv.slice(2)
|
|
156
247
|
|
|
@@ -159,6 +250,11 @@ async function main() {
|
|
|
159
250
|
process.exit(0)
|
|
160
251
|
}
|
|
161
252
|
|
|
253
|
+
if (command === '--version' || command === '-V') {
|
|
254
|
+
process.stdout.write(`${version}\n`)
|
|
255
|
+
process.exit(0)
|
|
256
|
+
}
|
|
257
|
+
|
|
162
258
|
if (command !== 'upload') {
|
|
163
259
|
if (command === 'help') {
|
|
164
260
|
printHelp()
|
|
@@ -175,15 +271,30 @@ async function main() {
|
|
|
175
271
|
process.exit(0)
|
|
176
272
|
}
|
|
177
273
|
|
|
274
|
+
if (options.version) {
|
|
275
|
+
process.stdout.write(`${version}\n`)
|
|
276
|
+
process.exit(0)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (options.json && options.quiet) {
|
|
280
|
+
throw new Error('Use either --json or --quiet, not both.')
|
|
281
|
+
}
|
|
282
|
+
|
|
178
283
|
const filePath = positional[0]
|
|
179
284
|
if (!filePath) {
|
|
180
285
|
throw new Error('Missing file path. Usage: bytifi upload <file>')
|
|
181
286
|
}
|
|
182
287
|
|
|
288
|
+
if (positional.length > 1) {
|
|
289
|
+
process.stderr.write(`Warning: ignoring extra files: ${positional.slice(1).join(', ')}\n`)
|
|
290
|
+
}
|
|
291
|
+
|
|
183
292
|
await runUpload(filePath, options)
|
|
184
293
|
}
|
|
185
294
|
|
|
186
295
|
main().catch((error) => {
|
|
187
|
-
|
|
296
|
+
clearProgressLine()
|
|
297
|
+
const verbose = process.argv.includes('--verbose')
|
|
298
|
+
printError(error, verbose)
|
|
188
299
|
process.exit(exitCodeForError(error))
|
|
189
300
|
})
|
package/lib/upload.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DIRECT_UPLOAD_LIMIT_BYTES,
|
|
3
|
-
collectEncryptedParts,
|
|
4
3
|
encryptChunkFromFile,
|
|
5
4
|
resolveUploadFile,
|
|
6
5
|
} from './crypto.js'
|
|
7
6
|
import fs from 'node:fs/promises'
|
|
8
7
|
|
|
9
8
|
const DEFAULT_BASE_URL = 'https://bytifi.com'
|
|
9
|
+
const DEFAULT_CONCURRENCY = 4
|
|
10
|
+
const POLL_INTERVAL_MS = 1500
|
|
11
|
+
const POLL_TIMEOUT_MS = 30 * 60 * 1000
|
|
12
|
+
const MAX_RETRIES = 3
|
|
10
13
|
|
|
11
14
|
export class BytifiApiError extends Error {
|
|
12
15
|
constructor(message, { status = 0, body = null } = {}) {
|
|
@@ -25,10 +28,22 @@ export class BytifiNetworkError extends Error {
|
|
|
25
28
|
}
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
function sleep(ms) {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
function normalizeBaseUrl(baseUrl) {
|
|
29
36
|
return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
30
37
|
}
|
|
31
38
|
|
|
39
|
+
function isRetryableError(error) {
|
|
40
|
+
if (error instanceof BytifiNetworkError) return true
|
|
41
|
+
if (error instanceof BytifiApiError) {
|
|
42
|
+
return error.status === 429 || error.status >= 500
|
|
43
|
+
}
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
async function readResponseBody(response) {
|
|
33
48
|
const text = await response.text()
|
|
34
49
|
if (!text) return null
|
|
@@ -40,7 +55,7 @@ async function readResponseBody(response) {
|
|
|
40
55
|
}
|
|
41
56
|
}
|
|
42
57
|
|
|
43
|
-
async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null } = {}) {
|
|
58
|
+
async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null, signal } = {}) {
|
|
44
59
|
const url = `${normalizeBaseUrl(baseUrl)}${path}`
|
|
45
60
|
const requestHeaders = {
|
|
46
61
|
Authorization: `Bearer ${apiKey}`,
|
|
@@ -54,8 +69,12 @@ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, b
|
|
|
54
69
|
method,
|
|
55
70
|
headers: requestHeaders,
|
|
56
71
|
body,
|
|
72
|
+
signal,
|
|
57
73
|
})
|
|
58
74
|
} catch (error) {
|
|
75
|
+
if (signal?.aborted) {
|
|
76
|
+
throw new Error('Upload aborted.')
|
|
77
|
+
}
|
|
59
78
|
throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
|
|
60
79
|
}
|
|
61
80
|
|
|
@@ -73,6 +92,29 @@ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, b
|
|
|
73
92
|
return payload
|
|
74
93
|
}
|
|
75
94
|
|
|
95
|
+
async function apiFetchWithRetry(baseUrl, path, options = {}) {
|
|
96
|
+
const { retries = MAX_RETRIES, signal } = options
|
|
97
|
+
|
|
98
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
99
|
+
try {
|
|
100
|
+
return await apiFetch(baseUrl, path, options)
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (signal?.aborted || error.message === 'Upload aborted.') {
|
|
103
|
+
throw error
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!isRetryableError(error) || attempt === retries) {
|
|
107
|
+
throw error
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const delayMs = Math.min(1000 * (2 ** attempt), 8000)
|
|
111
|
+
await sleep(delayMs)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
throw new Error('Request failed after retries.')
|
|
116
|
+
}
|
|
117
|
+
|
|
76
118
|
function buildShareUrl(payload, encryptionToken) {
|
|
77
119
|
return `${payload.url}#token=${encodeURIComponent(encryptionToken)}`
|
|
78
120
|
}
|
|
@@ -92,11 +134,34 @@ function buildResult(payload, context, shareUrl) {
|
|
|
92
134
|
}
|
|
93
135
|
}
|
|
94
136
|
|
|
137
|
+
async function collectEncryptedBuffer(filePath, context, { onProgress, signal } = {}) {
|
|
138
|
+
const fileHandle = await fs.open(filePath, 'r')
|
|
139
|
+
const encryptedParts = []
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
for (let chunkIndex = 0; chunkIndex < context.chunkCount; chunkIndex += 1) {
|
|
143
|
+
if (signal?.aborted) {
|
|
144
|
+
throw new Error('Upload aborted.')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
|
|
148
|
+
onProgress?.(Math.round(((chunkIndex + 1) / context.chunkCount) * 90))
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
await fileHandle.close()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
onProgress?.(95)
|
|
155
|
+
return Buffer.concat(encryptedParts)
|
|
156
|
+
}
|
|
157
|
+
|
|
95
158
|
async function uploadDirect(context, encryptedBuffer, {
|
|
96
159
|
apiKey,
|
|
97
160
|
baseUrl,
|
|
98
161
|
expiresInMinutes,
|
|
99
162
|
deleteOnDownload,
|
|
163
|
+
onProgress,
|
|
164
|
+
signal,
|
|
100
165
|
}) {
|
|
101
166
|
const formData = new FormData()
|
|
102
167
|
const blob = new Blob([encryptedBuffer], { type: 'application/octet-stream' })
|
|
@@ -107,33 +172,42 @@ async function uploadDirect(context, encryptedBuffer, {
|
|
|
107
172
|
formData.append('deleteOnDownload', deleteOnDownload ? 'true' : 'false')
|
|
108
173
|
formData.append('expiresInMinutes', String(expiresInMinutes))
|
|
109
174
|
|
|
110
|
-
const payload = await
|
|
175
|
+
const payload = await apiFetchWithRetry(baseUrl, '/api/public/upload', {
|
|
111
176
|
apiKey,
|
|
112
177
|
method: 'POST',
|
|
113
178
|
body: formData,
|
|
179
|
+
signal,
|
|
114
180
|
})
|
|
115
181
|
|
|
182
|
+
onProgress?.(100)
|
|
183
|
+
|
|
116
184
|
const shareUrl = buildShareUrl(payload, context.token)
|
|
117
185
|
return buildResult(payload, context, shareUrl)
|
|
118
186
|
}
|
|
119
187
|
|
|
120
188
|
async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
|
|
189
|
+
const startedAt = Date.now()
|
|
190
|
+
|
|
121
191
|
while (true) {
|
|
122
192
|
if (signal?.aborted) {
|
|
123
193
|
throw new Error('Upload aborted.')
|
|
124
194
|
}
|
|
125
195
|
|
|
126
|
-
|
|
196
|
+
if (Date.now() - startedAt > POLL_TIMEOUT_MS) {
|
|
197
|
+
throw new Error('Upload finalization timed out. Try again later.')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const payload = await apiFetchWithRetry(
|
|
127
201
|
baseUrl,
|
|
128
202
|
`/api/public/upload/status?sessionToken=${encodeURIComponent(sessionToken)}`,
|
|
129
|
-
{ apiKey },
|
|
203
|
+
{ apiKey, signal },
|
|
130
204
|
)
|
|
131
205
|
|
|
132
|
-
if (payload.status !== 'processing') {
|
|
206
|
+
if (payload.status !== 'processing' && payload.status !== 'pending') {
|
|
133
207
|
return payload
|
|
134
208
|
}
|
|
135
209
|
|
|
136
|
-
await
|
|
210
|
+
await sleep(POLL_INTERVAL_MS)
|
|
137
211
|
}
|
|
138
212
|
}
|
|
139
213
|
|
|
@@ -144,9 +218,10 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
144
218
|
deleteOnDownload,
|
|
145
219
|
onProgress,
|
|
146
220
|
signal,
|
|
221
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
147
222
|
}) {
|
|
148
223
|
const partSize = context.meta.chunkSize + 16
|
|
149
|
-
const initPayload = await
|
|
224
|
+
const initPayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/init', {
|
|
150
225
|
apiKey,
|
|
151
226
|
method: 'POST',
|
|
152
227
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -161,21 +236,35 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
161
236
|
expiresInMinutes,
|
|
162
237
|
deleteOnDownload,
|
|
163
238
|
}),
|
|
239
|
+
signal,
|
|
164
240
|
})
|
|
165
241
|
|
|
166
242
|
const sessionToken = initPayload.sessionToken
|
|
243
|
+
const workerCount = Math.min(
|
|
244
|
+
Number(initPayload.concurrency) || concurrency,
|
|
245
|
+
context.chunkCount,
|
|
246
|
+
)
|
|
167
247
|
const fileHandle = await fs.open(filePath, 'r')
|
|
248
|
+
let nextChunkIndex = 0
|
|
249
|
+
let completedParts = 0
|
|
168
250
|
|
|
169
|
-
|
|
170
|
-
|
|
251
|
+
async function uploadWorker() {
|
|
252
|
+
while (true) {
|
|
171
253
|
if (signal?.aborted) {
|
|
172
254
|
throw new Error('Upload aborted.')
|
|
173
255
|
}
|
|
174
256
|
|
|
257
|
+
const chunkIndex = nextChunkIndex
|
|
258
|
+
nextChunkIndex += 1
|
|
259
|
+
|
|
260
|
+
if (chunkIndex >= context.chunkCount) {
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
175
264
|
const partNumber = chunkIndex + 1
|
|
176
265
|
const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
|
|
177
266
|
|
|
178
|
-
await
|
|
267
|
+
await apiFetchWithRetry(
|
|
179
268
|
baseUrl,
|
|
180
269
|
`/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
|
|
181
270
|
{
|
|
@@ -183,17 +272,24 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
183
272
|
method: 'PUT',
|
|
184
273
|
headers: { 'Content-Type': 'application/octet-stream' },
|
|
185
274
|
body: encryptedPart,
|
|
275
|
+
signal,
|
|
186
276
|
},
|
|
187
277
|
)
|
|
188
278
|
|
|
189
|
-
|
|
279
|
+
completedParts += 1
|
|
280
|
+
onProgress?.(Math.round((completedParts / context.chunkCount) * 100))
|
|
190
281
|
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await Promise.all(Array.from({ length: workerCount }, () => uploadWorker()))
|
|
191
286
|
} catch (error) {
|
|
192
|
-
await
|
|
287
|
+
await apiFetchWithRetry(baseUrl, '/api/public/upload/abort', {
|
|
193
288
|
apiKey,
|
|
194
289
|
method: 'POST',
|
|
195
290
|
headers: { 'Content-Type': 'application/json' },
|
|
196
291
|
body: JSON.stringify({ sessionToken }),
|
|
292
|
+
signal,
|
|
197
293
|
}).catch(() => {})
|
|
198
294
|
|
|
199
295
|
throw error
|
|
@@ -201,14 +297,15 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
201
297
|
await fileHandle.close()
|
|
202
298
|
}
|
|
203
299
|
|
|
204
|
-
let completePayload = await
|
|
300
|
+
let completePayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/complete', {
|
|
205
301
|
apiKey,
|
|
206
302
|
method: 'POST',
|
|
207
303
|
headers: { 'Content-Type': 'application/json' },
|
|
208
304
|
body: JSON.stringify({ sessionToken }),
|
|
305
|
+
signal,
|
|
209
306
|
})
|
|
210
307
|
|
|
211
|
-
if (completePayload.status === 'processing') {
|
|
308
|
+
if (completePayload.status === 'processing' || completePayload.status === 'pending') {
|
|
212
309
|
completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal })
|
|
213
310
|
}
|
|
214
311
|
|
|
@@ -226,8 +323,10 @@ export async function uploadFile(filePath, options) {
|
|
|
226
323
|
})
|
|
227
324
|
|
|
228
325
|
if (context.encryptedSize <= DIRECT_UPLOAD_LIMIT_BYTES) {
|
|
229
|
-
const
|
|
230
|
-
|
|
326
|
+
const encryptedBuffer = await collectEncryptedBuffer(absolutePath, context, {
|
|
327
|
+
onProgress: options.onProgress,
|
|
328
|
+
signal: options.signal,
|
|
329
|
+
})
|
|
231
330
|
|
|
232
331
|
return uploadDirect(context, encryptedBuffer, options)
|
|
233
332
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bytifi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Official Bytifi CLI — encrypt and upload files from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"node": ">=18"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
|
-
"upload": "node bin/bytifi.js upload"
|
|
17
|
+
"upload": "node bin/bytifi.js upload",
|
|
18
|
+
"build:bundle": "esbuild bin/bytifi.js --bundle --platform=node --format=cjs --outfile=dist/bytifi-bundle.cjs",
|
|
19
|
+
"build:win": "npm run build:bundle && pkg dist/bytifi-bundle.cjs --targets node22-win-x64 --output dist/bytifi.exe --compress GZip"
|
|
18
20
|
},
|
|
19
21
|
"repository": {
|
|
20
22
|
"type": "git",
|
|
@@ -35,7 +37,8 @@
|
|
|
35
37
|
"file-sharing"
|
|
36
38
|
],
|
|
37
39
|
"license": "MIT",
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@yao-pkg/pkg": "^6.21.0",
|
|
42
|
+
"esbuild": "^0.25.0"
|
|
40
43
|
}
|
|
41
44
|
}
|