bytifi 0.1.0 → 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 +43 -10
- package/bin/bytifi.js +149 -38
- package/lib/crypto.js +105 -35
- package/lib/upload.js +161 -68
- package/package.json +9 -3
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,22 +20,51 @@ 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
|
|
32
53
|
bytifi upload ./photo.png
|
|
54
|
+
bytifi upload ./photo.png --api-key usk_your_api_key_here
|
|
33
55
|
bytifi upload ./photo.png --expires 60 --json
|
|
34
56
|
bytifi upload ./large.iso -q
|
|
35
57
|
```
|
|
36
58
|
|
|
59
|
+
Without a global install:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx bytifi upload ./photo.png --api-key usk_your_api_key_here
|
|
63
|
+
npm exec bytifi -- upload ./photo.png --api-key usk_your_api_key_here
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Note: with `npm exec`, put `--` before the file path so npm does not swallow `--api-key`.
|
|
67
|
+
|
|
37
68
|
### Options
|
|
38
69
|
|
|
39
70
|
| Flag | Description |
|
|
@@ -43,22 +74,24 @@ bytifi upload ./large.iso -q
|
|
|
43
74
|
| `--delete-on-download` | Delete after first download |
|
|
44
75
|
| `--json` | Machine-readable JSON output |
|
|
45
76
|
| `-q, --quiet` | Print only the share URL |
|
|
46
|
-
| `--
|
|
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.
|
|
47
86
|
|
|
48
87
|
## How it works
|
|
49
88
|
|
|
50
89
|
1. Encrypts the file locally with AES-GCM (same format as the website)
|
|
51
|
-
2. Uploads encrypted bytes via the public API
|
|
90
|
+
2. Uploads encrypted bytes via the public API (parallel part uploads for large files)
|
|
52
91
|
3. Prints a share URL including `#token=...`
|
|
53
92
|
|
|
54
|
-
The server never receives plaintext or the decryption token.
|
|
55
|
-
|
|
56
93
|
## Development
|
|
57
94
|
|
|
58
95
|
```bash
|
|
59
96
|
node bin/bytifi.js upload ./file.png --json
|
|
60
97
|
```
|
|
61
|
-
|
|
62
|
-
## Status
|
|
63
|
-
|
|
64
|
-
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/crypto.js
CHANGED
|
@@ -22,17 +22,6 @@ export function encryptChunk(plainChunk, key, noncePrefix, chunkIndex) {
|
|
|
22
22
|
return Buffer.concat([encrypted, tag])
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function createEncryptionToken(tokenBytes = crypto.randomBytes(32)) {
|
|
26
|
-
if (tokenBytes.length !== 32) {
|
|
27
|
-
throw new Error('Encryption token must be 32 bytes.')
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return {
|
|
31
|
-
tokenBytes,
|
|
32
|
-
token: toBase64Url(tokenBytes),
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
25
|
export function buildClientEncryptionMeta({
|
|
37
26
|
plainChunkSize,
|
|
38
27
|
chunkCount,
|
|
@@ -51,62 +40,143 @@ export function buildClientEncryptionMeta({
|
|
|
51
40
|
}
|
|
52
41
|
}
|
|
53
42
|
|
|
54
|
-
export
|
|
43
|
+
export function calculateEncryptedSize(originalSize, plainChunkSize = PLAIN_CHUNK_SIZE) {
|
|
44
|
+
const chunkCount = Math.ceil(originalSize / plainChunkSize) || 1
|
|
45
|
+
let encryptedSize = 0
|
|
46
|
+
|
|
47
|
+
for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) {
|
|
48
|
+
const start = chunkIndex * plainChunkSize
|
|
49
|
+
const plainSize = Math.min(originalSize - start, plainChunkSize)
|
|
50
|
+
encryptedSize += plainSize + 16
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { chunkCount, encryptedSize }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createEncryptionContext({
|
|
57
|
+
originalSize,
|
|
55
58
|
originalName = 'upload',
|
|
56
59
|
mimeType = 'application/octet-stream',
|
|
57
60
|
tokenBytes = crypto.randomBytes(32),
|
|
58
61
|
noncePrefix = crypto.randomBytes(8),
|
|
59
62
|
plainChunkSize = PLAIN_CHUNK_SIZE,
|
|
60
|
-
}
|
|
63
|
+
}) {
|
|
61
64
|
if (tokenBytes.length !== 32) throw new Error('Encryption token must be 32 bytes.')
|
|
62
65
|
if (noncePrefix.length !== 8) throw new Error('Nonce prefix must be 8 bytes.')
|
|
63
66
|
|
|
64
|
-
const
|
|
65
|
-
const chunkCount = Math.ceil(fileBuffer.length / plainChunkSize) || 1
|
|
66
|
-
const encryptedParts = []
|
|
67
|
-
let encryptedSize = 0
|
|
68
|
-
|
|
69
|
-
for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) {
|
|
70
|
-
const start = chunkIndex * plainChunkSize
|
|
71
|
-
const end = Math.min(fileBuffer.length, start + plainChunkSize)
|
|
72
|
-
const plainChunk = fileBuffer.subarray(start, end)
|
|
73
|
-
const encryptedChunk = encryptChunk(plainChunk, key, noncePrefix, chunkIndex)
|
|
74
|
-
encryptedParts.push(encryptedChunk)
|
|
75
|
-
encryptedSize += encryptedChunk.length
|
|
76
|
-
}
|
|
77
|
-
|
|
67
|
+
const { chunkCount, encryptedSize } = calculateEncryptedSize(originalSize, plainChunkSize)
|
|
78
68
|
const meta = buildClientEncryptionMeta({
|
|
79
69
|
plainChunkSize,
|
|
80
70
|
chunkCount,
|
|
81
71
|
noncePrefix,
|
|
82
|
-
originalSize
|
|
72
|
+
originalSize,
|
|
83
73
|
mimeType,
|
|
84
74
|
})
|
|
85
75
|
|
|
86
76
|
return {
|
|
87
77
|
token: toBase64Url(tokenBytes),
|
|
88
78
|
tokenBytes,
|
|
79
|
+
noncePrefix,
|
|
89
80
|
meta,
|
|
90
|
-
|
|
91
|
-
encryptedBuffer: Buffer.concat(encryptedParts),
|
|
81
|
+
chunkCount,
|
|
92
82
|
encryptedSize,
|
|
93
83
|
originalName,
|
|
94
84
|
mimeType,
|
|
95
|
-
originalSize
|
|
85
|
+
originalSize,
|
|
86
|
+
plainChunkSize,
|
|
96
87
|
}
|
|
97
88
|
}
|
|
98
89
|
|
|
99
|
-
export async function
|
|
90
|
+
export async function resolveUploadFile(filePath, options = {}) {
|
|
100
91
|
const absolutePath = path.resolve(filePath)
|
|
101
|
-
const
|
|
92
|
+
const stat = await fs.stat(absolutePath)
|
|
93
|
+
|
|
94
|
+
if (!stat.isFile()) {
|
|
95
|
+
throw new Error('Upload path must be a file.')
|
|
96
|
+
}
|
|
97
|
+
|
|
102
98
|
const originalName = options.originalName || path.basename(absolutePath)
|
|
103
99
|
const mimeType = options.mimeType || guessMimeType(originalName)
|
|
104
100
|
|
|
105
|
-
return
|
|
106
|
-
|
|
101
|
+
return {
|
|
102
|
+
absolutePath,
|
|
103
|
+
context: createEncryptionContext({
|
|
104
|
+
originalSize: stat.size,
|
|
105
|
+
originalName,
|
|
106
|
+
mimeType,
|
|
107
|
+
}),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readPlainChunk(fileHandle, chunkIndex, originalSize, plainChunkSize = PLAIN_CHUNK_SIZE) {
|
|
112
|
+
const start = chunkIndex * plainChunkSize
|
|
113
|
+
const length = Math.min(plainChunkSize, originalSize - start)
|
|
114
|
+
const buffer = Buffer.alloc(length)
|
|
115
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, length, start)
|
|
116
|
+
|
|
117
|
+
if (bytesRead !== length) {
|
|
118
|
+
throw new Error('File ended before the upload was complete.')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return buffer
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function encryptChunkFromFile(fileHandle, chunkIndex, context) {
|
|
125
|
+
const plainChunk = await readPlainChunk(
|
|
126
|
+
fileHandle,
|
|
127
|
+
chunkIndex,
|
|
128
|
+
context.originalSize,
|
|
129
|
+
context.plainChunkSize,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return encryptChunk(plainChunk, context.tokenBytes, context.noncePrefix, chunkIndex)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function collectEncryptedParts(filePath, context) {
|
|
136
|
+
const fileHandle = await fs.open(filePath, 'r')
|
|
137
|
+
const encryptedParts = []
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
for (let chunkIndex = 0; chunkIndex < context.chunkCount; chunkIndex += 1) {
|
|
141
|
+
encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
await fileHandle.close()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return encryptedParts
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function encryptFileBuffer(fileBuffer, {
|
|
151
|
+
originalName = 'upload',
|
|
152
|
+
mimeType = 'application/octet-stream',
|
|
153
|
+
tokenBytes = crypto.randomBytes(32),
|
|
154
|
+
noncePrefix = crypto.randomBytes(8),
|
|
155
|
+
plainChunkSize = PLAIN_CHUNK_SIZE,
|
|
156
|
+
} = {}) {
|
|
157
|
+
const context = createEncryptionContext({
|
|
158
|
+
originalSize: fileBuffer.length,
|
|
107
159
|
originalName,
|
|
108
160
|
mimeType,
|
|
161
|
+
tokenBytes,
|
|
162
|
+
noncePrefix,
|
|
163
|
+
plainChunkSize,
|
|
109
164
|
})
|
|
165
|
+
|
|
166
|
+
const encryptedParts = []
|
|
167
|
+
|
|
168
|
+
for (let chunkIndex = 0; chunkIndex < context.chunkCount; chunkIndex += 1) {
|
|
169
|
+
const start = chunkIndex * plainChunkSize
|
|
170
|
+
const end = Math.min(fileBuffer.length, start + plainChunkSize)
|
|
171
|
+
const plainChunk = fileBuffer.subarray(start, end)
|
|
172
|
+
encryptedParts.push(encryptChunk(plainChunk, context.tokenBytes, context.noncePrefix, chunkIndex))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
...context,
|
|
177
|
+
encryptedParts,
|
|
178
|
+
encryptedBuffer: Buffer.concat(encryptedParts),
|
|
179
|
+
}
|
|
110
180
|
}
|
|
111
181
|
|
|
112
182
|
export function decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunkIndex) {
|
package/lib/upload.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DIRECT_UPLOAD_LIMIT_BYTES,
|
|
3
|
+
encryptChunkFromFile,
|
|
4
|
+
resolveUploadFile,
|
|
5
|
+
} from './crypto.js'
|
|
6
|
+
import fs from 'node:fs/promises'
|
|
2
7
|
|
|
3
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
|
|
4
13
|
|
|
5
14
|
export class BytifiApiError extends Error {
|
|
6
15
|
constructor(message, { status = 0, body = null } = {}) {
|
|
@@ -19,10 +28,22 @@ export class BytifiNetworkError extends Error {
|
|
|
19
28
|
}
|
|
20
29
|
}
|
|
21
30
|
|
|
31
|
+
function sleep(ms) {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
function normalizeBaseUrl(baseUrl) {
|
|
23
36
|
return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
24
37
|
}
|
|
25
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
|
+
|
|
26
47
|
async function readResponseBody(response) {
|
|
27
48
|
const text = await response.text()
|
|
28
49
|
if (!text) return null
|
|
@@ -34,7 +55,7 @@ async function readResponseBody(response) {
|
|
|
34
55
|
}
|
|
35
56
|
}
|
|
36
57
|
|
|
37
|
-
async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null } = {}) {
|
|
58
|
+
async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null, signal } = {}) {
|
|
38
59
|
const url = `${normalizeBaseUrl(baseUrl)}${path}`
|
|
39
60
|
const requestHeaders = {
|
|
40
61
|
Authorization: `Bearer ${apiKey}`,
|
|
@@ -48,8 +69,12 @@ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, b
|
|
|
48
69
|
method,
|
|
49
70
|
headers: requestHeaders,
|
|
50
71
|
body,
|
|
72
|
+
signal,
|
|
51
73
|
})
|
|
52
74
|
} catch (error) {
|
|
75
|
+
if (signal?.aborted) {
|
|
76
|
+
throw new Error('Upload aborted.')
|
|
77
|
+
}
|
|
53
78
|
throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
|
|
54
79
|
}
|
|
55
80
|
|
|
@@ -67,18 +92,41 @@ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, b
|
|
|
67
92
|
return payload
|
|
68
93
|
}
|
|
69
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
|
+
|
|
70
118
|
function buildShareUrl(payload, encryptionToken) {
|
|
71
119
|
return `${payload.url}#token=${encodeURIComponent(encryptionToken)}`
|
|
72
120
|
}
|
|
73
121
|
|
|
74
|
-
function buildResult(payload,
|
|
122
|
+
function buildResult(payload, context, shareUrl) {
|
|
75
123
|
return {
|
|
76
124
|
shareUrl,
|
|
77
125
|
url: payload.url,
|
|
78
126
|
downloadUrl: payload.downloadUrl,
|
|
79
127
|
token: payload.token,
|
|
80
|
-
encryptionToken:
|
|
81
|
-
originalName: payload.originalName ||
|
|
128
|
+
encryptionToken: context.token,
|
|
129
|
+
originalName: payload.originalName || context.originalName,
|
|
82
130
|
size: payload.size,
|
|
83
131
|
expiresAt: payload.expiresAt,
|
|
84
132
|
deleteOnDownload: payload.deleteOnDownload,
|
|
@@ -86,157 +134,202 @@ function buildResult(payload, encryption, shareUrl) {
|
|
|
86
134
|
}
|
|
87
135
|
}
|
|
88
136
|
|
|
89
|
-
async function
|
|
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
|
+
|
|
158
|
+
async function uploadDirect(context, encryptedBuffer, {
|
|
90
159
|
apiKey,
|
|
91
160
|
baseUrl,
|
|
92
161
|
expiresInMinutes,
|
|
93
162
|
deleteOnDownload,
|
|
163
|
+
onProgress,
|
|
164
|
+
signal,
|
|
94
165
|
}) {
|
|
95
166
|
const formData = new FormData()
|
|
96
|
-
const blob = new Blob([
|
|
167
|
+
const blob = new Blob([encryptedBuffer], { type: 'application/octet-stream' })
|
|
97
168
|
|
|
98
|
-
formData.append('file', blob,
|
|
169
|
+
formData.append('file', blob, context.originalName)
|
|
99
170
|
formData.append('clientEncrypted', 'true')
|
|
100
|
-
formData.append('clientEncryptionMeta', JSON.stringify(
|
|
171
|
+
formData.append('clientEncryptionMeta', JSON.stringify(context.meta))
|
|
101
172
|
formData.append('deleteOnDownload', deleteOnDownload ? 'true' : 'false')
|
|
102
173
|
formData.append('expiresInMinutes', String(expiresInMinutes))
|
|
103
174
|
|
|
104
|
-
const payload = await
|
|
175
|
+
const payload = await apiFetchWithRetry(baseUrl, '/api/public/upload', {
|
|
105
176
|
apiKey,
|
|
106
177
|
method: 'POST',
|
|
107
178
|
body: formData,
|
|
179
|
+
signal,
|
|
108
180
|
})
|
|
109
181
|
|
|
110
|
-
|
|
111
|
-
|
|
182
|
+
onProgress?.(100)
|
|
183
|
+
|
|
184
|
+
const shareUrl = buildShareUrl(payload, context.token)
|
|
185
|
+
return buildResult(payload, context, shareUrl)
|
|
112
186
|
}
|
|
113
187
|
|
|
114
188
|
async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
|
|
189
|
+
const startedAt = Date.now()
|
|
190
|
+
|
|
115
191
|
while (true) {
|
|
116
192
|
if (signal?.aborted) {
|
|
117
193
|
throw new Error('Upload aborted.')
|
|
118
194
|
}
|
|
119
195
|
|
|
120
|
-
|
|
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(
|
|
121
201
|
baseUrl,
|
|
122
202
|
`/api/public/upload/status?sessionToken=${encodeURIComponent(sessionToken)}`,
|
|
123
|
-
{ apiKey },
|
|
203
|
+
{ apiKey, signal },
|
|
124
204
|
)
|
|
125
205
|
|
|
126
|
-
if (payload.status !== 'processing') {
|
|
206
|
+
if (payload.status !== 'processing' && payload.status !== 'pending') {
|
|
127
207
|
return payload
|
|
128
208
|
}
|
|
129
209
|
|
|
130
|
-
await
|
|
210
|
+
await sleep(POLL_INTERVAL_MS)
|
|
131
211
|
}
|
|
132
212
|
}
|
|
133
213
|
|
|
134
|
-
async function
|
|
214
|
+
async function uploadMultipartStreaming(filePath, context, {
|
|
135
215
|
apiKey,
|
|
136
216
|
baseUrl,
|
|
137
217
|
expiresInMinutes,
|
|
138
218
|
deleteOnDownload,
|
|
139
219
|
onProgress,
|
|
140
220
|
signal,
|
|
141
|
-
concurrency =
|
|
221
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
142
222
|
}) {
|
|
143
|
-
const partSize =
|
|
144
|
-
const initPayload = await
|
|
223
|
+
const partSize = context.meta.chunkSize + 16
|
|
224
|
+
const initPayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/init', {
|
|
145
225
|
apiKey,
|
|
146
226
|
method: 'POST',
|
|
147
227
|
headers: { 'Content-Type': 'application/json' },
|
|
148
228
|
body: JSON.stringify({
|
|
149
|
-
originalName:
|
|
150
|
-
mimeType:
|
|
151
|
-
size:
|
|
152
|
-
originalSize:
|
|
229
|
+
originalName: context.originalName,
|
|
230
|
+
mimeType: context.mimeType,
|
|
231
|
+
size: context.encryptedSize,
|
|
232
|
+
originalSize: context.originalSize,
|
|
153
233
|
clientEncrypted: true,
|
|
154
|
-
clientEncryptionMeta:
|
|
234
|
+
clientEncryptionMeta: context.meta,
|
|
155
235
|
partSize,
|
|
156
236
|
expiresInMinutes,
|
|
157
237
|
deleteOnDownload,
|
|
158
238
|
}),
|
|
239
|
+
signal,
|
|
159
240
|
})
|
|
160
241
|
|
|
161
242
|
const sessionToken = initPayload.sessionToken
|
|
162
|
-
const
|
|
243
|
+
const workerCount = Math.min(
|
|
244
|
+
Number(initPayload.concurrency) || concurrency,
|
|
245
|
+
context.chunkCount,
|
|
246
|
+
)
|
|
247
|
+
const fileHandle = await fs.open(filePath, 'r')
|
|
248
|
+
let nextChunkIndex = 0
|
|
163
249
|
let completedParts = 0
|
|
164
250
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
await apiFetch(
|
|
168
|
-
baseUrl,
|
|
169
|
-
`/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
|
|
170
|
-
{
|
|
171
|
-
apiKey,
|
|
172
|
-
method: 'PUT',
|
|
173
|
-
headers: { 'Content-Type': 'application/octet-stream' },
|
|
174
|
-
body: partBuffer,
|
|
175
|
-
},
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
completedParts += 1
|
|
179
|
-
onProgress?.(Math.round((completedParts / totalParts) * 100))
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const queue = Array.from({ length: totalParts }, (_, index) => index + 1)
|
|
183
|
-
const workers = Array.from({ length: Math.min(concurrency, totalParts) }, async () => {
|
|
184
|
-
while (queue.length > 0) {
|
|
251
|
+
async function uploadWorker() {
|
|
252
|
+
while (true) {
|
|
185
253
|
if (signal?.aborted) {
|
|
186
254
|
throw new Error('Upload aborted.')
|
|
187
255
|
}
|
|
188
256
|
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
257
|
+
const chunkIndex = nextChunkIndex
|
|
258
|
+
nextChunkIndex += 1
|
|
259
|
+
|
|
260
|
+
if (chunkIndex >= context.chunkCount) {
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const partNumber = chunkIndex + 1
|
|
265
|
+
const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
|
|
266
|
+
|
|
267
|
+
await apiFetchWithRetry(
|
|
268
|
+
baseUrl,
|
|
269
|
+
`/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
|
|
270
|
+
{
|
|
271
|
+
apiKey,
|
|
272
|
+
method: 'PUT',
|
|
273
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
274
|
+
body: encryptedPart,
|
|
275
|
+
signal,
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
completedParts += 1
|
|
280
|
+
onProgress?.(Math.round((completedParts / context.chunkCount) * 100))
|
|
192
281
|
}
|
|
193
|
-
}
|
|
282
|
+
}
|
|
194
283
|
|
|
195
284
|
try {
|
|
196
|
-
await Promise.all(
|
|
285
|
+
await Promise.all(Array.from({ length: workerCount }, () => uploadWorker()))
|
|
197
286
|
} catch (error) {
|
|
198
|
-
await
|
|
287
|
+
await apiFetchWithRetry(baseUrl, '/api/public/upload/abort', {
|
|
199
288
|
apiKey,
|
|
200
289
|
method: 'POST',
|
|
201
290
|
headers: { 'Content-Type': 'application/json' },
|
|
202
291
|
body: JSON.stringify({ sessionToken }),
|
|
292
|
+
signal,
|
|
203
293
|
}).catch(() => {})
|
|
204
294
|
|
|
205
295
|
throw error
|
|
296
|
+
} finally {
|
|
297
|
+
await fileHandle.close()
|
|
206
298
|
}
|
|
207
299
|
|
|
208
|
-
let completePayload = await
|
|
300
|
+
let completePayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/complete', {
|
|
209
301
|
apiKey,
|
|
210
302
|
method: 'POST',
|
|
211
303
|
headers: { 'Content-Type': 'application/json' },
|
|
212
304
|
body: JSON.stringify({ sessionToken }),
|
|
305
|
+
signal,
|
|
213
306
|
})
|
|
214
307
|
|
|
215
|
-
if (completePayload.status === 'processing') {
|
|
308
|
+
if (completePayload.status === 'processing' || completePayload.status === 'pending') {
|
|
216
309
|
completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal })
|
|
217
310
|
}
|
|
218
311
|
|
|
219
|
-
const shareUrl = buildShareUrl(completePayload,
|
|
220
|
-
return buildResult(completePayload,
|
|
312
|
+
const shareUrl = buildShareUrl(completePayload, context.token)
|
|
313
|
+
return buildResult(completePayload, context, shareUrl)
|
|
221
314
|
}
|
|
222
315
|
|
|
223
|
-
export async function
|
|
316
|
+
export async function uploadFile(filePath, options) {
|
|
224
317
|
if (!options?.apiKey) {
|
|
225
318
|
throw new Error('API key is required.')
|
|
226
319
|
}
|
|
227
320
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
321
|
+
const { absolutePath, context } = await resolveUploadFile(filePath, {
|
|
322
|
+
mimeType: options.mimeType,
|
|
323
|
+
})
|
|
231
324
|
|
|
232
|
-
|
|
233
|
-
|
|
325
|
+
if (context.encryptedSize <= DIRECT_UPLOAD_LIMIT_BYTES) {
|
|
326
|
+
const encryptedBuffer = await collectEncryptedBuffer(absolutePath, context, {
|
|
327
|
+
onProgress: options.onProgress,
|
|
328
|
+
signal: options.signal,
|
|
329
|
+
})
|
|
234
330
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const encryption = await encryptFile(filePath, {
|
|
238
|
-
mimeType: options?.mimeType,
|
|
239
|
-
})
|
|
331
|
+
return uploadDirect(context, encryptedBuffer, options)
|
|
332
|
+
}
|
|
240
333
|
|
|
241
|
-
return
|
|
334
|
+
return uploadMultipartStreaming(absolutePath, context, options)
|
|
242
335
|
}
|
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",
|
|
@@ -34,5 +36,9 @@
|
|
|
34
36
|
"upload",
|
|
35
37
|
"file-sharing"
|
|
36
38
|
],
|
|
37
|
-
"license": "MIT"
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@yao-pkg/pkg": "^6.21.0",
|
|
42
|
+
"esbuild": "^0.25.0"
|
|
43
|
+
}
|
|
38
44
|
}
|