bytifi 0.1.0 → 0.1.1
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 +10 -0
- package/bin/bytifi.js +1 -1
- package/lib/crypto.js +105 -35
- package/lib/upload.js +55 -61
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -30,10 +30,20 @@ Create an API key in **Account → API** on bytifi.com.
|
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
32
|
bytifi upload ./photo.png
|
|
33
|
+
bytifi upload ./photo.png --api-key usk_your_api_key_here
|
|
33
34
|
bytifi upload ./photo.png --expires 60 --json
|
|
34
35
|
bytifi upload ./large.iso -q
|
|
35
36
|
```
|
|
36
37
|
|
|
38
|
+
Without a global install:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx bytifi upload ./photo.png --api-key usk_your_api_key_here
|
|
42
|
+
npm exec bytifi -- upload ./photo.png --api-key usk_your_api_key_here
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Note: with `npm exec`, put `--` before the file path so npm does not swallow `--api-key`.
|
|
46
|
+
|
|
37
47
|
### Options
|
|
38
48
|
|
|
39
49
|
| Flag | Description |
|
package/bin/bytifi.js
CHANGED
|
@@ -126,7 +126,7 @@ async function runUpload(filePath, options) {
|
|
|
126
126
|
onProgress: options.quiet || options.json
|
|
127
127
|
? undefined
|
|
128
128
|
: (percent) => {
|
|
129
|
-
process.stderr.write(`
|
|
129
|
+
process.stderr.write(`Encrypting and uploading: ${percent}%\n`)
|
|
130
130
|
},
|
|
131
131
|
})
|
|
132
132
|
|
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,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DIRECT_UPLOAD_LIMIT_BYTES,
|
|
3
|
+
collectEncryptedParts,
|
|
4
|
+
encryptChunkFromFile,
|
|
5
|
+
resolveUploadFile,
|
|
6
|
+
} from './crypto.js'
|
|
7
|
+
import fs from 'node:fs/promises'
|
|
2
8
|
|
|
3
9
|
const DEFAULT_BASE_URL = 'https://bytifi.com'
|
|
4
10
|
|
|
@@ -71,14 +77,14 @@ function buildShareUrl(payload, encryptionToken) {
|
|
|
71
77
|
return `${payload.url}#token=${encodeURIComponent(encryptionToken)}`
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
function buildResult(payload,
|
|
80
|
+
function buildResult(payload, context, shareUrl) {
|
|
75
81
|
return {
|
|
76
82
|
shareUrl,
|
|
77
83
|
url: payload.url,
|
|
78
84
|
downloadUrl: payload.downloadUrl,
|
|
79
85
|
token: payload.token,
|
|
80
|
-
encryptionToken:
|
|
81
|
-
originalName: payload.originalName ||
|
|
86
|
+
encryptionToken: context.token,
|
|
87
|
+
originalName: payload.originalName || context.originalName,
|
|
82
88
|
size: payload.size,
|
|
83
89
|
expiresAt: payload.expiresAt,
|
|
84
90
|
deleteOnDownload: payload.deleteOnDownload,
|
|
@@ -86,18 +92,18 @@ function buildResult(payload, encryption, shareUrl) {
|
|
|
86
92
|
}
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
async function uploadDirect(
|
|
95
|
+
async function uploadDirect(context, encryptedBuffer, {
|
|
90
96
|
apiKey,
|
|
91
97
|
baseUrl,
|
|
92
98
|
expiresInMinutes,
|
|
93
99
|
deleteOnDownload,
|
|
94
100
|
}) {
|
|
95
101
|
const formData = new FormData()
|
|
96
|
-
const blob = new Blob([
|
|
102
|
+
const blob = new Blob([encryptedBuffer], { type: 'application/octet-stream' })
|
|
97
103
|
|
|
98
|
-
formData.append('file', blob,
|
|
104
|
+
formData.append('file', blob, context.originalName)
|
|
99
105
|
formData.append('clientEncrypted', 'true')
|
|
100
|
-
formData.append('clientEncryptionMeta', JSON.stringify(
|
|
106
|
+
formData.append('clientEncryptionMeta', JSON.stringify(context.meta))
|
|
101
107
|
formData.append('deleteOnDownload', deleteOnDownload ? 'true' : 'false')
|
|
102
108
|
formData.append('expiresInMinutes', String(expiresInMinutes))
|
|
103
109
|
|
|
@@ -107,8 +113,8 @@ async function uploadDirect(encryption, {
|
|
|
107
113
|
body: formData,
|
|
108
114
|
})
|
|
109
115
|
|
|
110
|
-
const shareUrl = buildShareUrl(payload,
|
|
111
|
-
return buildResult(payload,
|
|
116
|
+
const shareUrl = buildShareUrl(payload, context.token)
|
|
117
|
+
return buildResult(payload, context, shareUrl)
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
|
|
@@ -131,27 +137,26 @@ async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
|
|
|
131
137
|
}
|
|
132
138
|
}
|
|
133
139
|
|
|
134
|
-
async function
|
|
140
|
+
async function uploadMultipartStreaming(filePath, context, {
|
|
135
141
|
apiKey,
|
|
136
142
|
baseUrl,
|
|
137
143
|
expiresInMinutes,
|
|
138
144
|
deleteOnDownload,
|
|
139
145
|
onProgress,
|
|
140
146
|
signal,
|
|
141
|
-
concurrency = 3,
|
|
142
147
|
}) {
|
|
143
|
-
const partSize =
|
|
148
|
+
const partSize = context.meta.chunkSize + 16
|
|
144
149
|
const initPayload = await apiFetch(baseUrl, '/api/public/upload/init', {
|
|
145
150
|
apiKey,
|
|
146
151
|
method: 'POST',
|
|
147
152
|
headers: { 'Content-Type': 'application/json' },
|
|
148
153
|
body: JSON.stringify({
|
|
149
|
-
originalName:
|
|
150
|
-
mimeType:
|
|
151
|
-
size:
|
|
152
|
-
originalSize:
|
|
154
|
+
originalName: context.originalName,
|
|
155
|
+
mimeType: context.mimeType,
|
|
156
|
+
size: context.encryptedSize,
|
|
157
|
+
originalSize: context.originalSize,
|
|
153
158
|
clientEncrypted: true,
|
|
154
|
-
clientEncryptionMeta:
|
|
159
|
+
clientEncryptionMeta: context.meta,
|
|
155
160
|
partSize,
|
|
156
161
|
expiresInMinutes,
|
|
157
162
|
deleteOnDownload,
|
|
@@ -159,41 +164,30 @@ async function uploadMultipart(encryption, {
|
|
|
159
164
|
})
|
|
160
165
|
|
|
161
166
|
const sessionToken = initPayload.sessionToken
|
|
162
|
-
const
|
|
163
|
-
let completedParts = 0
|
|
167
|
+
const fileHandle = await fs.open(filePath, 'r')
|
|
164
168
|
|
|
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) {
|
|
169
|
+
try {
|
|
170
|
+
for (let chunkIndex = 0; chunkIndex < context.chunkCount; chunkIndex += 1) {
|
|
185
171
|
if (signal?.aborted) {
|
|
186
172
|
throw new Error('Upload aborted.')
|
|
187
173
|
}
|
|
188
174
|
|
|
189
|
-
const partNumber =
|
|
190
|
-
|
|
191
|
-
|
|
175
|
+
const partNumber = chunkIndex + 1
|
|
176
|
+
const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
|
|
177
|
+
|
|
178
|
+
await apiFetch(
|
|
179
|
+
baseUrl,
|
|
180
|
+
`/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
|
|
181
|
+
{
|
|
182
|
+
apiKey,
|
|
183
|
+
method: 'PUT',
|
|
184
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
185
|
+
body: encryptedPart,
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
onProgress?.(Math.round((partNumber / context.chunkCount) * 100))
|
|
192
190
|
}
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
try {
|
|
196
|
-
await Promise.all(workers)
|
|
197
191
|
} catch (error) {
|
|
198
192
|
await apiFetch(baseUrl, '/api/public/upload/abort', {
|
|
199
193
|
apiKey,
|
|
@@ -203,6 +197,8 @@ async function uploadMultipart(encryption, {
|
|
|
203
197
|
}).catch(() => {})
|
|
204
198
|
|
|
205
199
|
throw error
|
|
200
|
+
} finally {
|
|
201
|
+
await fileHandle.close()
|
|
206
202
|
}
|
|
207
203
|
|
|
208
204
|
let completePayload = await apiFetch(baseUrl, '/api/public/upload/complete', {
|
|
@@ -216,27 +212,25 @@ async function uploadMultipart(encryption, {
|
|
|
216
212
|
completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal })
|
|
217
213
|
}
|
|
218
214
|
|
|
219
|
-
const shareUrl = buildShareUrl(completePayload,
|
|
220
|
-
return buildResult(completePayload,
|
|
215
|
+
const shareUrl = buildShareUrl(completePayload, context.token)
|
|
216
|
+
return buildResult(completePayload, context, shareUrl)
|
|
221
217
|
}
|
|
222
218
|
|
|
223
|
-
export async function
|
|
219
|
+
export async function uploadFile(filePath, options) {
|
|
224
220
|
if (!options?.apiKey) {
|
|
225
221
|
throw new Error('API key is required.')
|
|
226
222
|
}
|
|
227
223
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
224
|
+
const { absolutePath, context } = await resolveUploadFile(filePath, {
|
|
225
|
+
mimeType: options.mimeType,
|
|
226
|
+
})
|
|
231
227
|
|
|
232
|
-
|
|
233
|
-
|
|
228
|
+
if (context.encryptedSize <= DIRECT_UPLOAD_LIMIT_BYTES) {
|
|
229
|
+
const encryptedParts = await collectEncryptedParts(absolutePath, context)
|
|
230
|
+
const encryptedBuffer = Buffer.concat(encryptedParts)
|
|
234
231
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const encryption = await encryptFile(filePath, {
|
|
238
|
-
mimeType: options?.mimeType,
|
|
239
|
-
})
|
|
232
|
+
return uploadDirect(context, encryptedBuffer, options)
|
|
233
|
+
}
|
|
240
234
|
|
|
241
|
-
return
|
|
235
|
+
return uploadMultipartStreaming(absolutePath, context, options)
|
|
242
236
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bytifi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Official Bytifi CLI — encrypt and upload files from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,5 +34,8 @@
|
|
|
34
34
|
"upload",
|
|
35
35
|
"file-sharing"
|
|
36
36
|
],
|
|
37
|
-
"license": "MIT"
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"bytifi": "^0.1.0"
|
|
40
|
+
}
|
|
38
41
|
}
|