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 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(`Uploading encrypted parts: ${percent}%\n`)
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 async function encryptFileBuffer(fileBuffer, {
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 key = tokenBytes
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: fileBuffer.length,
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
- encryptedParts,
91
- encryptedBuffer: Buffer.concat(encryptedParts),
81
+ chunkCount,
92
82
  encryptedSize,
93
83
  originalName,
94
84
  mimeType,
95
- originalSize: fileBuffer.length,
85
+ originalSize,
86
+ plainChunkSize,
96
87
  }
97
88
  }
98
89
 
99
- export async function encryptFile(filePath, options = {}) {
90
+ export async function resolveUploadFile(filePath, options = {}) {
100
91
  const absolutePath = path.resolve(filePath)
101
- const fileBuffer = await fs.readFile(absolutePath)
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 encryptFileBuffer(fileBuffer, {
106
- ...options,
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 { DIRECT_UPLOAD_LIMIT_BYTES } from './crypto.js'
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, encryption, shareUrl) {
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: encryption.token,
81
- originalName: payload.originalName || encryption.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(encryption, {
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([encryption.encryptedBuffer], { type: 'application/octet-stream' })
102
+ const blob = new Blob([encryptedBuffer], { type: 'application/octet-stream' })
97
103
 
98
- formData.append('file', blob, encryption.originalName)
104
+ formData.append('file', blob, context.originalName)
99
105
  formData.append('clientEncrypted', 'true')
100
- formData.append('clientEncryptionMeta', JSON.stringify(encryption.meta))
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, encryption.token)
111
- return buildResult(payload, encryption, shareUrl)
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 uploadMultipart(encryption, {
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 = encryption.meta.chunkSize + 16
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: encryption.originalName,
150
- mimeType: encryption.mimeType,
151
- size: encryption.encryptedSize,
152
- originalSize: encryption.originalSize,
154
+ originalName: context.originalName,
155
+ mimeType: context.mimeType,
156
+ size: context.encryptedSize,
157
+ originalSize: context.originalSize,
153
158
  clientEncrypted: true,
154
- clientEncryptionMeta: encryption.meta,
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 totalParts = encryption.encryptedParts.length
163
- let completedParts = 0
167
+ const fileHandle = await fs.open(filePath, 'r')
164
168
 
165
- const uploadPart = async (partNumber) => {
166
- const partBuffer = encryption.encryptedParts[partNumber - 1]
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 = queue.shift()
190
- if (!partNumber) return
191
- await uploadPart(partNumber)
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, encryption.token)
220
- return buildResult(completePayload, encryption, shareUrl)
215
+ const shareUrl = buildShareUrl(completePayload, context.token)
216
+ return buildResult(completePayload, context, shareUrl)
221
217
  }
222
218
 
223
- export async function uploadEncrypted(encryption, options) {
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
- if (encryption.encryptedSize <= DIRECT_UPLOAD_LIMIT_BYTES) {
229
- return uploadDirect(encryption, options)
230
- }
224
+ const { absolutePath, context } = await resolveUploadFile(filePath, {
225
+ mimeType: options.mimeType,
226
+ })
231
227
 
232
- return uploadMultipart(encryption, options)
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
- export async function uploadFile(filePath, options) {
236
- const { encryptFile } = await import('./crypto.js')
237
- const encryption = await encryptFile(filePath, {
238
- mimeType: options?.mimeType,
239
- })
232
+ return uploadDirect(context, encryptedBuffer, options)
233
+ }
240
234
 
241
- return uploadEncrypted(encryption, options)
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.0",
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
  }