bytifi 0.2.3 → 0.2.5
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/bin/bytifi.js +25 -14
- package/lib/decrypt.js +59 -63
- package/lib/progress.js +67 -0
- package/lib/upload.js +235 -120
- package/package.json +1 -1
package/bin/bytifi.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'node:path'
|
|
|
5
5
|
import process from 'node:process'
|
|
6
6
|
import { createRequire } from 'node:module'
|
|
7
7
|
import { decryptFile } from '../lib/decrypt.js'
|
|
8
|
+
import { formatProgressLine } from '../lib/progress.js'
|
|
8
9
|
import { BytifiApiError, BytifiNetworkError, uploadFile } from '../lib/upload.js'
|
|
9
10
|
|
|
10
11
|
const require = createRequire(import.meta.url)
|
|
@@ -287,8 +288,15 @@ function validateExpires(minutes) {
|
|
|
287
288
|
}
|
|
288
289
|
}
|
|
289
290
|
|
|
290
|
-
function writeProgress(
|
|
291
|
-
|
|
291
|
+
function writeProgress(info) {
|
|
292
|
+
const line = formatProgressLine(info)
|
|
293
|
+
if (line) {
|
|
294
|
+
process.stderr.write(`\r${line}`)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function finishProgressLine() {
|
|
299
|
+
process.stderr.write('\n')
|
|
292
300
|
}
|
|
293
301
|
|
|
294
302
|
function clearProgressLine() {
|
|
@@ -320,7 +328,7 @@ async function runUpload(filePath, options) {
|
|
|
320
328
|
process.on('SIGTERM', handleSignal)
|
|
321
329
|
|
|
322
330
|
const showProgress = !options.quiet && !options.json
|
|
323
|
-
let
|
|
331
|
+
let lastLine = ''
|
|
324
332
|
|
|
325
333
|
try {
|
|
326
334
|
const result = await uploadFile(resolvedPath, {
|
|
@@ -332,17 +340,18 @@ async function runUpload(filePath, options) {
|
|
|
332
340
|
concurrency: options.concurrency,
|
|
333
341
|
signal: abortController.signal,
|
|
334
342
|
onProgress: showProgress
|
|
335
|
-
? (
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
343
|
+
? (info) => {
|
|
344
|
+
const line = formatProgressLine(info)
|
|
345
|
+
if (line && line !== lastLine) {
|
|
346
|
+
lastLine = line
|
|
347
|
+
writeProgress(info)
|
|
339
348
|
}
|
|
340
349
|
}
|
|
341
350
|
: undefined,
|
|
342
351
|
})
|
|
343
352
|
|
|
344
353
|
if (showProgress) {
|
|
345
|
-
|
|
354
|
+
finishProgressLine()
|
|
346
355
|
}
|
|
347
356
|
|
|
348
357
|
if (options.json) {
|
|
@@ -375,7 +384,7 @@ async function runDecrypt(input, options) {
|
|
|
375
384
|
process.on('SIGTERM', handleSignal)
|
|
376
385
|
|
|
377
386
|
const showProgress = !options.quiet && !options.json
|
|
378
|
-
let
|
|
387
|
+
let lastLine = ''
|
|
379
388
|
|
|
380
389
|
try {
|
|
381
390
|
const result = await decryptFile(input, {
|
|
@@ -389,17 +398,18 @@ async function runDecrypt(input, options) {
|
|
|
389
398
|
baseUrl: options.baseUrl,
|
|
390
399
|
signal: abortController.signal,
|
|
391
400
|
onProgress: showProgress
|
|
392
|
-
? (
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
401
|
+
? (info) => {
|
|
402
|
+
const line = formatProgressLine(info)
|
|
403
|
+
if (line && line !== lastLine) {
|
|
404
|
+
lastLine = line
|
|
405
|
+
writeProgress(info)
|
|
396
406
|
}
|
|
397
407
|
}
|
|
398
408
|
: undefined,
|
|
399
409
|
})
|
|
400
410
|
|
|
401
411
|
if (showProgress) {
|
|
402
|
-
|
|
412
|
+
finishProgressLine()
|
|
403
413
|
}
|
|
404
414
|
|
|
405
415
|
if (options.json) {
|
|
@@ -531,6 +541,7 @@ async function main() {
|
|
|
531
541
|
|
|
532
542
|
main().catch((error) => {
|
|
533
543
|
clearProgressLine()
|
|
544
|
+
finishProgressLine()
|
|
534
545
|
const verbose = process.argv.includes('--verbose')
|
|
535
546
|
printError(error, verbose)
|
|
536
547
|
process.exit(exitCodeForError(error))
|
package/lib/decrypt.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
usesChunkCompression,
|
|
10
10
|
} from './crypto.js'
|
|
11
11
|
import { fromBase64Url } from './base64url.js'
|
|
12
|
-
import { BytifiApiError,
|
|
12
|
+
import { BytifiApiError, fetchPublicBinaryWithRetry, publicFetchWithRetry } from './upload.js'
|
|
13
13
|
|
|
14
14
|
const DEFAULT_BASE_URL = 'https://bytifi.com'
|
|
15
15
|
|
|
@@ -17,45 +17,6 @@ function normalizeBaseUrl(baseUrl) {
|
|
|
17
17
|
return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
async function readResponseBody(response) {
|
|
21
|
-
const text = await response.text()
|
|
22
|
-
if (!text) return null
|
|
23
|
-
|
|
24
|
-
try {
|
|
25
|
-
return JSON.parse(text)
|
|
26
|
-
} catch {
|
|
27
|
-
return text
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function publicFetch(baseUrl, requestPath, { signal } = {}) {
|
|
32
|
-
const url = `${normalizeBaseUrl(baseUrl)}${requestPath}`
|
|
33
|
-
|
|
34
|
-
let response
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
response = await fetch(url, { signal })
|
|
38
|
-
} catch (error) {
|
|
39
|
-
if (signal?.aborted) {
|
|
40
|
-
throw new Error('Decrypt aborted.')
|
|
41
|
-
}
|
|
42
|
-
throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const payload = await readResponseBody(response)
|
|
46
|
-
|
|
47
|
-
if (!response.ok) {
|
|
48
|
-
const message = typeof payload === 'object' && payload?.error
|
|
49
|
-
? payload.error
|
|
50
|
-
: typeof payload === 'string' && payload
|
|
51
|
-
? payload
|
|
52
|
-
: `Request failed with status ${response.status}.`
|
|
53
|
-
throw new BytifiApiError(message, { status: response.status, body: payload })
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return payload
|
|
57
|
-
}
|
|
58
|
-
|
|
59
20
|
export function parseDecryptInput(input, { encryptionToken = '', baseUrl = DEFAULT_BASE_URL } = {}) {
|
|
60
21
|
const trimmed = String(input || '').trim()
|
|
61
22
|
if (!trimmed) {
|
|
@@ -168,6 +129,7 @@ async function resolveEncryptionMeta({
|
|
|
168
129
|
linkToken = '',
|
|
169
130
|
baseUrl = DEFAULT_BASE_URL,
|
|
170
131
|
signal,
|
|
132
|
+
onStatus,
|
|
171
133
|
}) {
|
|
172
134
|
if (inlineMeta) {
|
|
173
135
|
return { meta: inlineMeta }
|
|
@@ -184,7 +146,7 @@ async function resolveEncryptionMeta({
|
|
|
184
146
|
)
|
|
185
147
|
}
|
|
186
148
|
|
|
187
|
-
const linkInfo = await fetchLinkInfo(baseUrl, linkToken, signal)
|
|
149
|
+
const linkInfo = await fetchLinkInfo(baseUrl, linkToken, signal, onStatus)
|
|
188
150
|
|
|
189
151
|
if (linkInfo.status === 'expired') {
|
|
190
152
|
throw new Error('This file link has expired.')
|
|
@@ -207,25 +169,16 @@ function sanitizeOutputName(filename) {
|
|
|
207
169
|
return base || 'download'
|
|
208
170
|
}
|
|
209
171
|
|
|
210
|
-
async function fetchLinkInfo(baseUrl, linkToken, signal) {
|
|
211
|
-
return
|
|
172
|
+
async function fetchLinkInfo(baseUrl, linkToken, signal, onStatus) {
|
|
173
|
+
return publicFetchWithRetry(baseUrl, `/api/link/${encodeURIComponent(linkToken)}`, { signal, onStatus })
|
|
212
174
|
}
|
|
213
175
|
|
|
214
|
-
async function fetchEncryptedPart(baseUrl, linkToken, partNumber, signal) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
{
|
|
176
|
+
async function fetchEncryptedPart(baseUrl, linkToken, partNumber, signal, onStatus) {
|
|
177
|
+
return fetchPublicBinaryWithRetry(
|
|
178
|
+
baseUrl,
|
|
179
|
+
`/f/${encodeURIComponent(linkToken)}/p/${partNumber}`,
|
|
180
|
+
{ signal, onStatus },
|
|
218
181
|
)
|
|
219
|
-
|
|
220
|
-
if (!response.ok) {
|
|
221
|
-
const text = await response.text()
|
|
222
|
-
throw new BytifiApiError(text || `Failed to download part ${partNumber}.`, {
|
|
223
|
-
status: response.status,
|
|
224
|
-
body: text,
|
|
225
|
-
})
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return Buffer.from(await response.arrayBuffer())
|
|
229
182
|
}
|
|
230
183
|
|
|
231
184
|
async function decryptFromParts({
|
|
@@ -236,6 +189,7 @@ async function decryptFromParts({
|
|
|
236
189
|
noncePrefix,
|
|
237
190
|
outputPath,
|
|
238
191
|
onProgress,
|
|
192
|
+
onStatus,
|
|
239
193
|
signal,
|
|
240
194
|
}) {
|
|
241
195
|
const { chunks } = buildEncryptedChunkPlan(meta)
|
|
@@ -249,7 +203,27 @@ async function decryptFromParts({
|
|
|
249
203
|
}
|
|
250
204
|
|
|
251
205
|
const chunk = chunks[index]
|
|
252
|
-
const
|
|
206
|
+
const partNumber = chunk.chunkIndex + 1
|
|
207
|
+
|
|
208
|
+
onProgress?.({
|
|
209
|
+
stage: 'downloading',
|
|
210
|
+
part: partNumber,
|
|
211
|
+
totalParts: chunks.length,
|
|
212
|
+
downloadedBytes: decryptedBytes,
|
|
213
|
+
totalBytes: meta.originalSize,
|
|
214
|
+
percent: Math.round((decryptedBytes / meta.originalSize) * 100),
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const encryptedPart = await fetchEncryptedPart(baseUrl, linkToken, partNumber, signal, onStatus)
|
|
218
|
+
|
|
219
|
+
onProgress?.({
|
|
220
|
+
stage: 'decrypting',
|
|
221
|
+
part: partNumber,
|
|
222
|
+
totalParts: chunks.length,
|
|
223
|
+
partBytes: encryptedPart.length,
|
|
224
|
+
percent: Math.round((decryptedBytes / meta.originalSize) * 100),
|
|
225
|
+
})
|
|
226
|
+
|
|
253
227
|
const plainPart = await decryptPlainChunkFromEncrypted(
|
|
254
228
|
encryptedPart,
|
|
255
229
|
tokenBytes,
|
|
@@ -260,11 +234,22 @@ async function decryptFromParts({
|
|
|
260
234
|
|
|
261
235
|
await fileHandle.write(plainPart)
|
|
262
236
|
decryptedBytes += plainPart.length
|
|
263
|
-
|
|
237
|
+
|
|
238
|
+
onProgress?.({
|
|
239
|
+
stage: 'writing',
|
|
240
|
+
part: partNumber,
|
|
241
|
+
totalParts: chunks.length,
|
|
242
|
+
downloadedBytes: decryptedBytes,
|
|
243
|
+
totalBytes: meta.originalSize,
|
|
244
|
+
percent: Math.round((decryptedBytes / meta.originalSize) * 100),
|
|
245
|
+
detail: `${partNumber}/${chunks.length} parts`,
|
|
246
|
+
})
|
|
264
247
|
}
|
|
265
248
|
} finally {
|
|
266
249
|
await fileHandle.close()
|
|
267
250
|
}
|
|
251
|
+
|
|
252
|
+
onProgress?.({ stage: 'complete', percent: 100 })
|
|
268
253
|
}
|
|
269
254
|
|
|
270
255
|
async function decryptFromSingleFile({
|
|
@@ -702,6 +687,7 @@ async function decryptLocalFile(inputPath, options = {}) {
|
|
|
702
687
|
linkToken,
|
|
703
688
|
baseUrl: resolvedOptions.baseUrl || shareReference.baseUrl,
|
|
704
689
|
signal: resolvedOptions.signal,
|
|
690
|
+
onStatus: resolvedOptions.onStatus,
|
|
705
691
|
})
|
|
706
692
|
const meta = resolved.meta || resolved
|
|
707
693
|
const linkInfo = resolved.linkInfo || null
|
|
@@ -767,13 +753,21 @@ async function decryptLocalFile(inputPath, options = {}) {
|
|
|
767
753
|
|
|
768
754
|
export async function decryptFile(input, options = {}) {
|
|
769
755
|
const resolvedOptions = await applyUploadJsonDefaults(options)
|
|
756
|
+
const reportStatus = (info) => {
|
|
757
|
+
resolvedOptions.onStatus?.(info)
|
|
758
|
+
resolvedOptions.onProgress?.(info)
|
|
759
|
+
}
|
|
770
760
|
const trimmedInput = String(input || '').trim()
|
|
771
761
|
if (!trimmedInput) {
|
|
772
762
|
throw new Error('Missing share URL, link token, or encrypted file path.')
|
|
773
763
|
}
|
|
774
764
|
|
|
775
765
|
if (resolvedOptions.localFile || (!looksLikeRemoteInput(trimmedInput) && await pathExists(trimmedInput))) {
|
|
776
|
-
return decryptLocalFile(trimmedInput,
|
|
766
|
+
return decryptLocalFile(trimmedInput, {
|
|
767
|
+
...resolvedOptions,
|
|
768
|
+
onProgress: reportStatus,
|
|
769
|
+
onStatus: reportStatus,
|
|
770
|
+
})
|
|
777
771
|
}
|
|
778
772
|
|
|
779
773
|
const parsed = parseDecryptInput(trimmedInput, {
|
|
@@ -781,7 +775,7 @@ export async function decryptFile(input, options = {}) {
|
|
|
781
775
|
baseUrl: resolvedOptions.baseUrl,
|
|
782
776
|
})
|
|
783
777
|
|
|
784
|
-
const linkInfo = await fetchLinkInfo(parsed.baseUrl, parsed.linkToken, resolvedOptions.signal)
|
|
778
|
+
const linkInfo = await fetchLinkInfo(parsed.baseUrl, parsed.linkToken, resolvedOptions.signal, reportStatus)
|
|
785
779
|
|
|
786
780
|
if (linkInfo.status === 'expired') {
|
|
787
781
|
throw new Error('This file link has expired.')
|
|
@@ -815,7 +809,8 @@ export async function decryptFile(input, options = {}) {
|
|
|
815
809
|
tokenBytes,
|
|
816
810
|
noncePrefix,
|
|
817
811
|
outputPath,
|
|
818
|
-
onProgress:
|
|
812
|
+
onProgress: reportStatus,
|
|
813
|
+
onStatus: reportStatus,
|
|
819
814
|
signal: resolvedOptions.signal,
|
|
820
815
|
})
|
|
821
816
|
} else {
|
|
@@ -828,7 +823,8 @@ export async function decryptFile(input, options = {}) {
|
|
|
828
823
|
tokenBytes,
|
|
829
824
|
noncePrefix,
|
|
830
825
|
outputPath,
|
|
831
|
-
onProgress:
|
|
826
|
+
onProgress: reportStatus,
|
|
827
|
+
onStatus: reportStatus,
|
|
832
828
|
signal: resolvedOptions.signal,
|
|
833
829
|
})
|
|
834
830
|
}
|
package/lib/progress.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export function formatBytes(bytes) {
|
|
2
|
+
const value = Number(bytes)
|
|
3
|
+
if (!Number.isFinite(value) || value < 0) return '0 B'
|
|
4
|
+
|
|
5
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
6
|
+
let size = value
|
|
7
|
+
let unitIndex = 0
|
|
8
|
+
|
|
9
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
10
|
+
size /= 1024
|
|
11
|
+
unitIndex += 1
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const digits = size >= 100 || unitIndex === 0 ? 0 : size >= 10 ? 1 : 2
|
|
15
|
+
return `${size.toFixed(digits)} ${units[unitIndex]}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatDuration(ms) {
|
|
19
|
+
const totalSeconds = Math.max(1, Math.ceil(Number(ms) / 1000))
|
|
20
|
+
if (totalSeconds < 60) return `${totalSeconds}s`
|
|
21
|
+
|
|
22
|
+
const minutes = Math.floor(totalSeconds / 60)
|
|
23
|
+
const seconds = totalSeconds % 60
|
|
24
|
+
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatProgressLine(info) {
|
|
28
|
+
if (typeof info === 'number') {
|
|
29
|
+
return `${info}%`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!info || typeof info !== 'object') {
|
|
33
|
+
return ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (info.stage === 'waiting') {
|
|
37
|
+
return info.message || 'Waiting…'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const segments = []
|
|
41
|
+
|
|
42
|
+
if (info.part && info.totalParts) {
|
|
43
|
+
segments.push(`part ${info.part}/${info.totalParts}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (info.stage) {
|
|
47
|
+
segments.push(info.stage)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (info.partBytes) {
|
|
51
|
+
segments.push(formatBytes(info.partBytes))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (Number.isFinite(info.downloadedBytes) && Number.isFinite(info.totalBytes)) {
|
|
55
|
+
segments.push(`${formatBytes(info.downloadedBytes)}/${formatBytes(info.totalBytes)}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (Number.isFinite(info.percent)) {
|
|
59
|
+
segments.push(`${info.percent}%`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (info.detail) {
|
|
63
|
+
segments.push(info.detail)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return segments.join(' · ')
|
|
67
|
+
}
|
package/lib/upload.js
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
encryptChunkFromFile,
|
|
4
4
|
resolveUploadFile,
|
|
5
5
|
} from './crypto.js'
|
|
6
|
+
import { formatDuration } from './progress.js'
|
|
6
7
|
import fs from 'node:fs/promises'
|
|
7
8
|
|
|
8
9
|
const DEFAULT_BASE_URL = 'https://bytifi.com'
|
|
@@ -10,13 +11,15 @@ const DEFAULT_CONCURRENCY = 4
|
|
|
10
11
|
const POLL_INTERVAL_MS = 1500
|
|
11
12
|
const POLL_TIMEOUT_MS = 30 * 60 * 1000
|
|
12
13
|
const MAX_RETRIES = 3
|
|
14
|
+
const UNLIMITED_RATE_LIMIT_RETRIES = Number.POSITIVE_INFINITY
|
|
13
15
|
|
|
14
16
|
export class BytifiApiError extends Error {
|
|
15
|
-
constructor(message, { status = 0, body = null } = {}) {
|
|
17
|
+
constructor(message, { status = 0, body = null, retryAfterMs = null } = {}) {
|
|
16
18
|
super(message)
|
|
17
19
|
this.name = 'BytifiApiError'
|
|
18
20
|
this.status = status
|
|
19
21
|
this.body = body
|
|
22
|
+
this.retryAfterMs = retryAfterMs
|
|
20
23
|
}
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -44,6 +47,34 @@ function isRetryableError(error) {
|
|
|
44
47
|
return false
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
function parseRetryAfterMs(response) {
|
|
51
|
+
const retryAfter = response.headers.get('Retry-After')
|
|
52
|
+
if (retryAfter) {
|
|
53
|
+
const seconds = Number(retryAfter)
|
|
54
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
55
|
+
return seconds * 1000
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const dateMs = Date.parse(retryAfter)
|
|
59
|
+
if (Number.isFinite(dateMs)) {
|
|
60
|
+
return Math.max(0, dateMs - Date.now())
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const resetHeader = response.headers.get('RateLimit-Reset')
|
|
65
|
+
if (resetHeader) {
|
|
66
|
+
const resetValue = Number(resetHeader)
|
|
67
|
+
if (Number.isFinite(resetValue)) {
|
|
68
|
+
if (resetValue > 1_000_000_000) {
|
|
69
|
+
return Math.max(0, resetValue * 1000 - Date.now())
|
|
70
|
+
}
|
|
71
|
+
return Math.max(0, resetValue * 1000)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
47
78
|
async function readResponseBody(response) {
|
|
48
79
|
const text = await response.text()
|
|
49
80
|
if (!text) return null
|
|
@@ -55,13 +86,16 @@ async function readResponseBody(response) {
|
|
|
55
86
|
}
|
|
56
87
|
}
|
|
57
88
|
|
|
58
|
-
async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null, signal } = {}) {
|
|
89
|
+
async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null, signal, binary = false } = {}) {
|
|
59
90
|
const url = `${normalizeBaseUrl(baseUrl)}${path}`
|
|
60
91
|
const requestHeaders = {
|
|
61
|
-
Authorization: `Bearer ${apiKey}`,
|
|
62
92
|
...headers,
|
|
63
93
|
}
|
|
64
94
|
|
|
95
|
+
if (apiKey) {
|
|
96
|
+
requestHeaders.Authorization = `Bearer ${apiKey}`
|
|
97
|
+
}
|
|
98
|
+
|
|
65
99
|
let response
|
|
66
100
|
|
|
67
101
|
try {
|
|
@@ -78,41 +112,110 @@ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, b
|
|
|
78
112
|
throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
|
|
79
113
|
}
|
|
80
114
|
|
|
81
|
-
const
|
|
115
|
+
const responseBuffer = Buffer.from(await response.arrayBuffer())
|
|
82
116
|
|
|
83
117
|
if (!response.ok) {
|
|
118
|
+
const text = responseBuffer.toString('utf8')
|
|
119
|
+
let payload = text
|
|
120
|
+
try {
|
|
121
|
+
payload = JSON.parse(text)
|
|
122
|
+
} catch {
|
|
123
|
+
// keep text
|
|
124
|
+
}
|
|
125
|
+
|
|
84
126
|
const message = typeof payload === 'object' && payload?.error
|
|
85
127
|
? payload.error
|
|
86
128
|
: typeof payload === 'string' && payload
|
|
87
129
|
? payload
|
|
88
130
|
: `Request failed with status ${response.status}.`
|
|
89
|
-
throw new BytifiApiError(message, {
|
|
131
|
+
throw new BytifiApiError(message, {
|
|
132
|
+
status: response.status,
|
|
133
|
+
body: payload,
|
|
134
|
+
retryAfterMs: response.status === 429 ? parseRetryAfterMs(response) : null,
|
|
135
|
+
})
|
|
90
136
|
}
|
|
91
137
|
|
|
92
|
-
|
|
138
|
+
if (binary) {
|
|
139
|
+
return responseBuffer
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const text = responseBuffer.toString('utf8')
|
|
143
|
+
if (!text) return null
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(text)
|
|
147
|
+
} catch {
|
|
148
|
+
return text
|
|
149
|
+
}
|
|
93
150
|
}
|
|
94
151
|
|
|
95
152
|
async function apiFetchWithRetry(baseUrl, path, options = {}) {
|
|
96
|
-
const {
|
|
153
|
+
const {
|
|
154
|
+
retries = MAX_RETRIES,
|
|
155
|
+
rateLimitRetries = 0,
|
|
156
|
+
onStatus,
|
|
157
|
+
signal,
|
|
158
|
+
...fetchOptions
|
|
159
|
+
} = options
|
|
160
|
+
|
|
161
|
+
let attempt = 0
|
|
162
|
+
let rateLimitAttempt = 0
|
|
163
|
+
const maxRateLimitRetries = rateLimitRetries === UNLIMITED_RATE_LIMIT_RETRIES
|
|
164
|
+
? UNLIMITED_RATE_LIMIT_RETRIES
|
|
165
|
+
: (rateLimitRetries || retries)
|
|
97
166
|
|
|
98
|
-
|
|
167
|
+
while (true) {
|
|
99
168
|
try {
|
|
100
|
-
return await apiFetch(baseUrl, path,
|
|
169
|
+
return await apiFetch(baseUrl, path, { ...fetchOptions, signal })
|
|
101
170
|
} catch (error) {
|
|
102
|
-
if (signal?.aborted || error.message === 'Upload aborted.') {
|
|
171
|
+
if (signal?.aborted || error.message === 'Upload aborted.' || error.message === 'Decrypt aborted.') {
|
|
103
172
|
throw error
|
|
104
173
|
}
|
|
105
174
|
|
|
106
|
-
if (
|
|
175
|
+
if (error instanceof BytifiApiError && error.status === 429) {
|
|
176
|
+
if (rateLimitAttempt >= maxRateLimitRetries) {
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
rateLimitAttempt += 1
|
|
181
|
+
const waitMs = error.retryAfterMs ?? Math.min(600_000, 15_000 * rateLimitAttempt)
|
|
182
|
+
|
|
183
|
+
onStatus?.({
|
|
184
|
+
stage: 'waiting',
|
|
185
|
+
message: `Rate limited — waiting ${formatDuration(waitMs)} before retry (${rateLimitAttempt})…`,
|
|
186
|
+
waitMs,
|
|
187
|
+
retryAttempt: rateLimitAttempt,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
await sleep(waitMs)
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!isRetryableError(error) || attempt >= retries) {
|
|
107
195
|
throw error
|
|
108
196
|
}
|
|
109
197
|
|
|
110
|
-
|
|
111
|
-
await sleep(
|
|
198
|
+
attempt += 1
|
|
199
|
+
await sleep(Math.min(1000 * (2 ** attempt), 8000))
|
|
112
200
|
}
|
|
113
201
|
}
|
|
202
|
+
}
|
|
114
203
|
|
|
115
|
-
|
|
204
|
+
export async function publicFetchWithRetry(baseUrl, path, options = {}) {
|
|
205
|
+
return apiFetchWithRetry(baseUrl, path, {
|
|
206
|
+
...options,
|
|
207
|
+
apiKey: null,
|
|
208
|
+
rateLimitRetries: options.rateLimitRetries ?? UNLIMITED_RATE_LIMIT_RETRIES,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function fetchPublicBinaryWithRetry(baseUrl, path, options = {}) {
|
|
213
|
+
return apiFetchWithRetry(baseUrl, path, {
|
|
214
|
+
...options,
|
|
215
|
+
apiKey: null,
|
|
216
|
+
binary: true,
|
|
217
|
+
rateLimitRetries: options.rateLimitRetries ?? UNLIMITED_RATE_LIMIT_RETRIES,
|
|
218
|
+
})
|
|
116
219
|
}
|
|
117
220
|
|
|
118
221
|
function buildShareUrl(payload, encryptionToken) {
|
|
@@ -136,69 +239,6 @@ function buildResult(payload, context, shareUrl) {
|
|
|
136
239
|
}
|
|
137
240
|
}
|
|
138
241
|
|
|
139
|
-
function createBoundedQueue(maxSize) {
|
|
140
|
-
const items = []
|
|
141
|
-
const waiters = []
|
|
142
|
-
let closed = false
|
|
143
|
-
|
|
144
|
-
function notifyWaiters() {
|
|
145
|
-
while (waiters.length > 0) {
|
|
146
|
-
const resolve = waiters.shift()
|
|
147
|
-
resolve()
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return {
|
|
152
|
-
async push(item, signal) {
|
|
153
|
-
while (items.length >= maxSize) {
|
|
154
|
-
if (signal?.aborted) {
|
|
155
|
-
throw new Error('Upload aborted.')
|
|
156
|
-
}
|
|
157
|
-
await new Promise((resolve, reject) => {
|
|
158
|
-
const onAbort = () => {
|
|
159
|
-
const index = waiters.indexOf(resolve)
|
|
160
|
-
if (index >= 0) waiters.splice(index, 1)
|
|
161
|
-
reject(new Error('Upload aborted.'))
|
|
162
|
-
}
|
|
163
|
-
waiters.push(resolve)
|
|
164
|
-
signal?.addEventListener('abort', onAbort, { once: true })
|
|
165
|
-
})
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
items.push(item)
|
|
169
|
-
notifyWaiters()
|
|
170
|
-
},
|
|
171
|
-
async take(signal) {
|
|
172
|
-
while (items.length === 0) {
|
|
173
|
-
if (closed) {
|
|
174
|
-
return null
|
|
175
|
-
}
|
|
176
|
-
if (signal?.aborted) {
|
|
177
|
-
throw new Error('Upload aborted.')
|
|
178
|
-
}
|
|
179
|
-
await new Promise((resolve, reject) => {
|
|
180
|
-
const onAbort = () => {
|
|
181
|
-
const index = waiters.indexOf(resolve)
|
|
182
|
-
if (index >= 0) waiters.splice(index, 1)
|
|
183
|
-
reject(new Error('Upload aborted.'))
|
|
184
|
-
}
|
|
185
|
-
waiters.push(resolve)
|
|
186
|
-
signal?.addEventListener('abort', onAbort, { once: true })
|
|
187
|
-
})
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return items.shift()
|
|
191
|
-
},
|
|
192
|
-
close() {
|
|
193
|
-
closed = true
|
|
194
|
-
notifyWaiters()
|
|
195
|
-
},
|
|
196
|
-
get pending() {
|
|
197
|
-
return items.length
|
|
198
|
-
},
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
242
|
async function collectEncryptedBuffer(filePath, context, { onProgress, signal } = {}) {
|
|
203
243
|
const fileHandle = await fs.open(filePath, 'r')
|
|
204
244
|
const encryptedParts = []
|
|
@@ -209,14 +249,26 @@ async function collectEncryptedBuffer(filePath, context, { onProgress, signal }
|
|
|
209
249
|
throw new Error('Upload aborted.')
|
|
210
250
|
}
|
|
211
251
|
|
|
252
|
+
onProgress?.({
|
|
253
|
+
stage: 'encrypting',
|
|
254
|
+
part: chunkIndex + 1,
|
|
255
|
+
totalParts: context.chunkCount,
|
|
256
|
+
percent: Math.round((chunkIndex / context.chunkCount) * 90),
|
|
257
|
+
})
|
|
258
|
+
|
|
212
259
|
encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
|
|
213
|
-
onProgress?.(
|
|
260
|
+
onProgress?.({
|
|
261
|
+
stage: 'encrypted',
|
|
262
|
+
part: chunkIndex + 1,
|
|
263
|
+
totalParts: context.chunkCount,
|
|
264
|
+
percent: Math.round(((chunkIndex + 1) / context.chunkCount) * 90),
|
|
265
|
+
})
|
|
214
266
|
}
|
|
215
267
|
} finally {
|
|
216
268
|
await fileHandle.close()
|
|
217
269
|
}
|
|
218
270
|
|
|
219
|
-
onProgress?.(95)
|
|
271
|
+
onProgress?.({ stage: 'uploading', percent: 95, detail: 'direct upload' })
|
|
220
272
|
return Buffer.concat(encryptedParts)
|
|
221
273
|
}
|
|
222
274
|
|
|
@@ -226,6 +278,7 @@ async function uploadDirect(context, encryptedBuffer, {
|
|
|
226
278
|
expiresInMinutes,
|
|
227
279
|
deleteOnDownload,
|
|
228
280
|
onProgress,
|
|
281
|
+
onStatus,
|
|
229
282
|
signal,
|
|
230
283
|
}) {
|
|
231
284
|
const formData = new FormData()
|
|
@@ -242,15 +295,17 @@ async function uploadDirect(context, encryptedBuffer, {
|
|
|
242
295
|
method: 'POST',
|
|
243
296
|
body: formData,
|
|
244
297
|
signal,
|
|
298
|
+
onStatus,
|
|
299
|
+
rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
|
|
245
300
|
})
|
|
246
301
|
|
|
247
|
-
onProgress?.(100)
|
|
302
|
+
onProgress?.({ stage: 'complete', percent: 100 })
|
|
248
303
|
|
|
249
304
|
const shareUrl = buildShareUrl(payload, context.token)
|
|
250
305
|
return buildResult(payload, context, shareUrl)
|
|
251
306
|
}
|
|
252
307
|
|
|
253
|
-
async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
|
|
308
|
+
async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal, onStatus }) {
|
|
254
309
|
const startedAt = Date.now()
|
|
255
310
|
|
|
256
311
|
while (true) {
|
|
@@ -265,27 +320,57 @@ async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
|
|
|
265
320
|
const payload = await apiFetchWithRetry(
|
|
266
321
|
baseUrl,
|
|
267
322
|
`/api/public/upload/status?sessionToken=${encodeURIComponent(sessionToken)}`,
|
|
268
|
-
{
|
|
323
|
+
{
|
|
324
|
+
apiKey,
|
|
325
|
+
signal,
|
|
326
|
+
onStatus,
|
|
327
|
+
rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
|
|
328
|
+
},
|
|
269
329
|
)
|
|
270
330
|
|
|
271
331
|
if (payload.status !== 'processing' && payload.status !== 'pending') {
|
|
272
332
|
return payload
|
|
273
333
|
}
|
|
274
334
|
|
|
335
|
+
onStatus?.({
|
|
336
|
+
stage: 'finalizing',
|
|
337
|
+
percent: Math.min(99, Math.round(Number(payload?.progress?.percent || 0))),
|
|
338
|
+
detail: payload?.progress?.phase || 'processing on server',
|
|
339
|
+
})
|
|
340
|
+
|
|
275
341
|
await sleep(POLL_INTERVAL_MS)
|
|
276
342
|
}
|
|
277
343
|
}
|
|
278
344
|
|
|
345
|
+
function resolveUploadConcurrency(originalSize, requested) {
|
|
346
|
+
const requestedValue = Number(requested)
|
|
347
|
+
if (Number.isFinite(requestedValue) && requestedValue > 0) {
|
|
348
|
+
return Math.min(16, Math.max(1, Math.floor(requestedValue)))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (originalSize >= 3 * 1024 * 1024 * 1024) return 2
|
|
352
|
+
if (originalSize >= 1024 * 1024 * 1024) return 3
|
|
353
|
+
return DEFAULT_CONCURRENCY
|
|
354
|
+
}
|
|
355
|
+
|
|
279
356
|
async function uploadMultipartStreaming(filePath, context, {
|
|
280
357
|
apiKey,
|
|
281
358
|
baseUrl,
|
|
282
359
|
expiresInMinutes,
|
|
283
360
|
deleteOnDownload,
|
|
284
361
|
onProgress,
|
|
362
|
+
onStatus,
|
|
285
363
|
signal,
|
|
286
364
|
concurrency = DEFAULT_CONCURRENCY,
|
|
287
365
|
}) {
|
|
288
366
|
const maxPartSize = context.meta.chunkSize + 16
|
|
367
|
+
|
|
368
|
+
onProgress?.({
|
|
369
|
+
stage: 'starting',
|
|
370
|
+
percent: 0,
|
|
371
|
+
detail: `${context.chunkCount} parts · ${context.compression === 'gzip' ? 'gzip' : 'raw'} chunks`,
|
|
372
|
+
})
|
|
373
|
+
|
|
289
374
|
const initPayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/init', {
|
|
290
375
|
apiKey,
|
|
291
376
|
method: 'POST',
|
|
@@ -302,25 +387,32 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
302
387
|
deleteOnDownload,
|
|
303
388
|
}),
|
|
304
389
|
signal,
|
|
390
|
+
onStatus,
|
|
391
|
+
rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
|
|
305
392
|
})
|
|
306
393
|
|
|
307
394
|
const sessionToken = initPayload.sessionToken
|
|
308
395
|
const workerCount = Math.min(
|
|
309
|
-
|
|
396
|
+
resolveUploadConcurrency(context.originalSize, concurrency || initPayload.concurrency),
|
|
310
397
|
context.chunkCount,
|
|
311
398
|
)
|
|
312
|
-
const queue = createBoundedQueue(Math.max(2, workerCount * 2))
|
|
313
399
|
const fileHandle = await fs.open(filePath, 'r')
|
|
314
400
|
let nextChunkIndex = 0
|
|
315
401
|
let completedParts = 0
|
|
316
402
|
|
|
403
|
+
onProgress?.({
|
|
404
|
+
stage: 'uploading',
|
|
405
|
+
percent: 0,
|
|
406
|
+
detail: `${workerCount} workers · session ${sessionToken.slice(0, 8)}…`,
|
|
407
|
+
})
|
|
408
|
+
|
|
317
409
|
function claimChunkIndex() {
|
|
318
410
|
const chunkIndex = nextChunkIndex
|
|
319
411
|
nextChunkIndex += 1
|
|
320
412
|
return chunkIndex
|
|
321
413
|
}
|
|
322
414
|
|
|
323
|
-
async function
|
|
415
|
+
async function pipelineWorker() {
|
|
324
416
|
while (true) {
|
|
325
417
|
if (signal?.aborted) {
|
|
326
418
|
throw new Error('Upload aborted.')
|
|
@@ -331,74 +423,87 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
331
423
|
return
|
|
332
424
|
}
|
|
333
425
|
|
|
334
|
-
const
|
|
335
|
-
await queue.push({
|
|
336
|
-
partNumber: chunkIndex + 1,
|
|
337
|
-
body: encryptedPart,
|
|
338
|
-
}, signal)
|
|
339
|
-
}
|
|
340
|
-
}
|
|
426
|
+
const partNumber = chunkIndex + 1
|
|
341
427
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
428
|
+
onProgress?.({
|
|
429
|
+
stage: 'encrypting',
|
|
430
|
+
part: partNumber,
|
|
431
|
+
totalParts: context.chunkCount,
|
|
432
|
+
percent: Math.round((completedParts / context.chunkCount) * 100),
|
|
433
|
+
})
|
|
347
434
|
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
435
|
+
const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
|
|
436
|
+
|
|
437
|
+
onProgress?.({
|
|
438
|
+
stage: 'uploading',
|
|
439
|
+
part: partNumber,
|
|
440
|
+
totalParts: context.chunkCount,
|
|
441
|
+
partBytes: encryptedPart.length,
|
|
442
|
+
percent: Math.round((completedParts / context.chunkCount) * 100),
|
|
443
|
+
})
|
|
352
444
|
|
|
353
445
|
await apiFetchWithRetry(
|
|
354
446
|
baseUrl,
|
|
355
|
-
`/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${
|
|
447
|
+
`/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
|
|
356
448
|
{
|
|
357
449
|
apiKey,
|
|
358
450
|
method: 'PUT',
|
|
359
451
|
headers: { 'Content-Type': 'application/octet-stream' },
|
|
360
|
-
body:
|
|
452
|
+
body: encryptedPart,
|
|
361
453
|
signal,
|
|
454
|
+
onStatus,
|
|
455
|
+
rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
|
|
362
456
|
},
|
|
363
457
|
)
|
|
364
458
|
|
|
365
459
|
completedParts += 1
|
|
366
|
-
onProgress?.(
|
|
460
|
+
onProgress?.({
|
|
461
|
+
stage: 'uploaded',
|
|
462
|
+
part: partNumber,
|
|
463
|
+
totalParts: context.chunkCount,
|
|
464
|
+
partBytes: encryptedPart.length,
|
|
465
|
+
percent: Math.round((completedParts / context.chunkCount) * 100),
|
|
466
|
+
detail: `${completedParts}/${context.chunkCount} parts done`,
|
|
467
|
+
})
|
|
367
468
|
}
|
|
368
469
|
}
|
|
369
470
|
|
|
370
471
|
try {
|
|
371
|
-
|
|
372
|
-
const encryptWorkers = Array.from({ length: workerCount }, () => encryptWorker())
|
|
373
|
-
await Promise.all(encryptWorkers)
|
|
374
|
-
queue.close()
|
|
375
|
-
await Promise.all(uploadWorkers)
|
|
472
|
+
await Promise.all(Array.from({ length: workerCount }, () => pipelineWorker()))
|
|
376
473
|
} catch (error) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
474
|
+
if (!(error instanceof BytifiApiError && error.status === 429)) {
|
|
475
|
+
await apiFetchWithRetry(baseUrl, '/api/public/upload/abort', {
|
|
476
|
+
apiKey,
|
|
477
|
+
method: 'POST',
|
|
478
|
+
headers: { 'Content-Type': 'application/json' },
|
|
479
|
+
body: JSON.stringify({ sessionToken }),
|
|
480
|
+
signal,
|
|
481
|
+
}).catch(() => {})
|
|
482
|
+
}
|
|
384
483
|
|
|
385
484
|
throw error
|
|
386
485
|
} finally {
|
|
387
486
|
await fileHandle.close()
|
|
388
487
|
}
|
|
389
488
|
|
|
489
|
+
onProgress?.({ stage: 'finalizing', percent: 99, detail: 'completing upload session' })
|
|
490
|
+
|
|
390
491
|
let completePayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/complete', {
|
|
391
492
|
apiKey,
|
|
392
493
|
method: 'POST',
|
|
393
494
|
headers: { 'Content-Type': 'application/json' },
|
|
394
495
|
body: JSON.stringify({ sessionToken }),
|
|
395
496
|
signal,
|
|
497
|
+
onStatus,
|
|
498
|
+
rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
|
|
396
499
|
})
|
|
397
500
|
|
|
398
501
|
if (completePayload.status === 'processing' || completePayload.status === 'pending') {
|
|
399
|
-
completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal })
|
|
502
|
+
completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal, onStatus })
|
|
400
503
|
}
|
|
401
504
|
|
|
505
|
+
onProgress?.({ stage: 'complete', percent: 100 })
|
|
506
|
+
|
|
402
507
|
const shareUrl = buildShareUrl(completePayload, context.token)
|
|
403
508
|
return buildResult(completePayload, context, shareUrl)
|
|
404
509
|
}
|
|
@@ -408,18 +513,28 @@ export async function uploadFile(filePath, options) {
|
|
|
408
513
|
throw new Error('API key is required.')
|
|
409
514
|
}
|
|
410
515
|
|
|
516
|
+
const reportStatus = (info) => {
|
|
517
|
+
options.onStatus?.(info)
|
|
518
|
+
options.onProgress?.(info)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const uploadOptions = {
|
|
522
|
+
...options,
|
|
523
|
+
onStatus: reportStatus,
|
|
524
|
+
}
|
|
525
|
+
|
|
411
526
|
const { absolutePath, context } = await resolveUploadFile(filePath, {
|
|
412
527
|
mimeType: options.mimeType,
|
|
413
528
|
})
|
|
414
529
|
|
|
415
530
|
if (context.originalSize <= MULTIPART_THRESHOLD_BYTES) {
|
|
416
531
|
const encryptedBuffer = await collectEncryptedBuffer(absolutePath, context, {
|
|
417
|
-
onProgress:
|
|
532
|
+
onProgress: reportStatus,
|
|
418
533
|
signal: options.signal,
|
|
419
534
|
})
|
|
420
535
|
|
|
421
|
-
return uploadDirect(context, encryptedBuffer,
|
|
536
|
+
return uploadDirect(context, encryptedBuffer, uploadOptions)
|
|
422
537
|
}
|
|
423
538
|
|
|
424
|
-
return uploadMultipartStreaming(absolutePath, context,
|
|
539
|
+
return uploadMultipartStreaming(absolutePath, context, uploadOptions)
|
|
425
540
|
}
|