@gentleduck/upload 0.2.5 → 0.3.0
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 +297 -0
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -90
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/__tests__/emitter.test.ts +85 -0
- package/src/__tests__/fingerprint.test.ts +49 -0
- package/src/__tests__/guards.test.ts +40 -0
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/__tests__/persistence.test.ts +191 -0
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/__tests__/store-libs.test.ts +192 -0
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/__tests__/transport-libs.test.ts +42 -0
- package/src/__tests__/transport.test.ts +182 -0
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +373 -0
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +13 -39
- package/src/core/contracts/transport/transport.ts +96 -90
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +490 -0
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +93 -53
- package/src/core/engine/store/handlers/add-file.ts +43 -48
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +54 -39
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +197 -94
- package/src/core/engine/store/store.runtime.ts +52 -53
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +8 -4
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +126 -0
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +59 -95
- package/src/react/uploader.types.ts +80 -0
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +97 -93
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +211 -116
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +113 -71
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-BNxznRif.js +0 -2
- package/dist/index-BXg9WIce.d.ts +0 -987
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -222
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File hashing entry point.
|
|
3
|
+
*
|
|
4
|
+
* Small files use `crypto.subtle.digest` (native, fastest). Large files are
|
|
5
|
+
* hashed incrementally so memory stays flat, off the main thread via a Web
|
|
6
|
+
* Worker when the environment supports one, or inline on the current thread as
|
|
7
|
+
* a fallback (Node/SSR/tests, or browsers without Worker/Blob URL support).
|
|
8
|
+
*
|
|
9
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { DEFAULT_HASH_CHUNK_SIZE, sha256HexOfBlob } from './sha256'
|
|
13
|
+
|
|
14
|
+
export { DEFAULT_HASH_CHUNK_SIZE, sha256HexOfBlob }
|
|
15
|
+
|
|
16
|
+
/** Native Web Crypto digest of a fully-buffered blob. Lowercase hex. */
|
|
17
|
+
async function sha256HexViaSubtle(blob: Blob): Promise<string> {
|
|
18
|
+
const buf = await blob.arrayBuffer()
|
|
19
|
+
const digest = await crypto.subtle.digest('SHA-256', buf)
|
|
20
|
+
const bytes = new Uint8Array(digest)
|
|
21
|
+
let out = ''
|
|
22
|
+
for (const b of bytes) out += b.toString(16).padStart(2, '0')
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True when this realm can spin up a Blob-URL Web Worker. */
|
|
27
|
+
function canUseWorker(): boolean {
|
|
28
|
+
return (
|
|
29
|
+
typeof Worker !== 'undefined' &&
|
|
30
|
+
typeof Blob !== 'undefined' &&
|
|
31
|
+
typeof URL !== 'undefined' &&
|
|
32
|
+
typeof URL.createObjectURL === 'function'
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let workerScriptUrl: string | null = null
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build (once) an object-URL for a worker that runs {@link sha256HexOfBlob}.
|
|
40
|
+
* The function is self-contained, so stringifying it yields valid worker source.
|
|
41
|
+
*/
|
|
42
|
+
function getWorkerScriptUrl(): string {
|
|
43
|
+
if (workerScriptUrl) return workerScriptUrl
|
|
44
|
+
const source = `const sha256HexOfBlob = ${sha256HexOfBlob.toString()};
|
|
45
|
+
self.onmessage = async (e) => {
|
|
46
|
+
try {
|
|
47
|
+
const hex = await sha256HexOfBlob(e.data.blob, { chunkSize: e.data.chunkSize });
|
|
48
|
+
self.postMessage({ ok: true, hex });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
self.postMessage({ ok: false, error: String((err && err.message) || err) });
|
|
51
|
+
}
|
|
52
|
+
};`
|
|
53
|
+
const blob = new Blob([source], { type: 'application/javascript' })
|
|
54
|
+
workerScriptUrl = URL.createObjectURL(blob)
|
|
55
|
+
return workerScriptUrl
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Hash `blob` in a Web Worker. Rejects if the worker errors. */
|
|
59
|
+
function sha256HexInWorker(blob: Blob, chunkSize: number): Promise<string> {
|
|
60
|
+
return new Promise<string>((resolve, reject) => {
|
|
61
|
+
let worker: Worker
|
|
62
|
+
try {
|
|
63
|
+
worker = new Worker(getWorkerScriptUrl())
|
|
64
|
+
} catch (err) {
|
|
65
|
+
reject(err instanceof Error ? err : new Error(String(err)))
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const done = (fn: () => void) => {
|
|
70
|
+
worker.onmessage = null
|
|
71
|
+
worker.onerror = null
|
|
72
|
+
worker.terminate()
|
|
73
|
+
fn()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
worker.onmessage = (e: MessageEvent<{ ok: boolean; hex?: string; error?: string }>) => {
|
|
77
|
+
const data = e.data
|
|
78
|
+
if (data.ok && typeof data.hex === 'string') {
|
|
79
|
+
done(() => resolve(data.hex as string))
|
|
80
|
+
} else {
|
|
81
|
+
done(() => reject(new Error(data.error ?? 'hash worker failed')))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
worker.onerror = (e) => {
|
|
85
|
+
done(() => reject(new Error(e.message || 'hash worker error')))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
worker.postMessage({ blob, chunkSize })
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Compute the lowercase hex SHA-256 of `blob`.
|
|
94
|
+
*
|
|
95
|
+
* @param blob - Source bytes.
|
|
96
|
+
* @param opts.inlineMaxSize - At or below this size, hash inline with Web Crypto
|
|
97
|
+
* (fast, buffers the whole blob). Above it, hash incrementally — off-thread
|
|
98
|
+
* when a Worker is available, otherwise inline-incremental. Defaults to
|
|
99
|
+
* {@link DEFAULT_HASH_CHUNK_SIZE}.
|
|
100
|
+
* @param opts.chunkSize - Slice size for incremental hashing.
|
|
101
|
+
* @param opts.signal - Abort signal (honored on the inline-incremental path).
|
|
102
|
+
*/
|
|
103
|
+
export async function hashBlob(
|
|
104
|
+
blob: Blob,
|
|
105
|
+
opts?: { inlineMaxSize?: number; chunkSize?: number; signal?: AbortSignal },
|
|
106
|
+
): Promise<string> {
|
|
107
|
+
const inlineMax =
|
|
108
|
+
typeof opts?.inlineMaxSize === 'number' && Number.isFinite(opts.inlineMaxSize) && opts.inlineMaxSize > 0
|
|
109
|
+
? opts.inlineMaxSize
|
|
110
|
+
: DEFAULT_HASH_CHUNK_SIZE
|
|
111
|
+
const chunkSize = opts?.chunkSize ?? DEFAULT_HASH_CHUNK_SIZE
|
|
112
|
+
|
|
113
|
+
if (blob.size <= inlineMax && typeof crypto !== 'undefined' && crypto.subtle) {
|
|
114
|
+
return sha256HexViaSubtle(blob)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (canUseWorker() && !opts?.signal) {
|
|
118
|
+
try {
|
|
119
|
+
return await sha256HexInWorker(blob, chunkSize)
|
|
120
|
+
} catch {
|
|
121
|
+
// Fall through to inline-incremental on any worker failure.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return opts?.signal ? sha256HexOfBlob(blob, { chunkSize, signal: opts.signal }) : sha256HexOfBlob(blob, { chunkSize })
|
|
126
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// biome-ignore-all lint/style/noNonNullAssertion: fixed-bounds reads over Uint8/Uint32Array in the SHA-256 hot loop are always defined; asserting avoids per-read branches.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure-JS incremental SHA-256 over a `Blob`/`File`.
|
|
5
|
+
*
|
|
6
|
+
* Web Crypto (`crypto.subtle.digest`) has no streaming/incremental API, so it
|
|
7
|
+
* must buffer the whole file in memory. This implementation reads the blob in
|
|
8
|
+
* bounded slices and folds each into the digest state, so memory stays flat
|
|
9
|
+
* regardless of file size.
|
|
10
|
+
*
|
|
11
|
+
* The whole algorithm lives inside a single self-contained function with no
|
|
12
|
+
* external references (constants and helpers are declared locally). That lets
|
|
13
|
+
* {@link ./index} stringify it with `Function.prototype.toString` and run it
|
|
14
|
+
* verbatim inside a Web Worker, keeping large-file hashing off the main thread.
|
|
15
|
+
*
|
|
16
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Default slice size fed to the hasher: 8 MiB. */
|
|
20
|
+
export const DEFAULT_HASH_CHUNK_SIZE = 8 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compute the lowercase hex SHA-256 digest of `blob`, reading it in bounded
|
|
24
|
+
* slices so peak memory is `~chunkSize` rather than the whole file.
|
|
25
|
+
*
|
|
26
|
+
* @param blob - Source bytes (a `File` is a `Blob`).
|
|
27
|
+
* @param opts.chunkSize - Slice size in bytes. Defaults to {@link DEFAULT_HASH_CHUNK_SIZE}.
|
|
28
|
+
* @param opts.signal - Optional abort signal, checked between slices.
|
|
29
|
+
* @returns 64-char lowercase hex digest.
|
|
30
|
+
*/
|
|
31
|
+
export async function sha256HexOfBlob(
|
|
32
|
+
blob: Blob,
|
|
33
|
+
opts?: { chunkSize?: number; signal?: AbortSignal },
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
const chunkSize = opts?.chunkSize && opts.chunkSize > 0 ? opts.chunkSize : 8 * 1024 * 1024
|
|
36
|
+
const signal = opts?.signal
|
|
37
|
+
|
|
38
|
+
// Round constants (first 32 bits of the fractional parts of the cube roots
|
|
39
|
+
// of the first 64 primes).
|
|
40
|
+
const K = new Uint32Array([
|
|
41
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98,
|
|
42
|
+
0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
|
|
43
|
+
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8,
|
|
44
|
+
0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
|
45
|
+
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819,
|
|
46
|
+
0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
|
|
47
|
+
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
|
|
48
|
+
0xc67178f2,
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
let h0 = 0x6a09e667
|
|
52
|
+
let h1 = 0xbb67ae85
|
|
53
|
+
let h2 = 0x3c6ef372
|
|
54
|
+
let h3 = 0xa54ff53a
|
|
55
|
+
let h4 = 0x510e527f
|
|
56
|
+
let h5 = 0x9b05688c
|
|
57
|
+
let h6 = 0x1f83d9ab
|
|
58
|
+
let h7 = 0x5be0cd19
|
|
59
|
+
|
|
60
|
+
const w = new Uint32Array(64)
|
|
61
|
+
const block = new Uint8Array(64)
|
|
62
|
+
let blockLen = 0
|
|
63
|
+
let totalLen = 0
|
|
64
|
+
|
|
65
|
+
const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n))
|
|
66
|
+
|
|
67
|
+
const compress = (): void => {
|
|
68
|
+
for (let i = 0; i < 16; i++) {
|
|
69
|
+
const j = i * 4
|
|
70
|
+
w[i] = ((block[j]! << 24) | (block[j + 1]! << 16) | (block[j + 2]! << 8) | block[j + 3]!) >>> 0
|
|
71
|
+
}
|
|
72
|
+
for (let i = 16; i < 64; i++) {
|
|
73
|
+
const x15 = w[i - 15]!
|
|
74
|
+
const x2 = w[i - 2]!
|
|
75
|
+
const s0 = rotr(x15, 7) ^ rotr(x15, 18) ^ (x15 >>> 3)
|
|
76
|
+
const s1 = rotr(x2, 17) ^ rotr(x2, 19) ^ (x2 >>> 10)
|
|
77
|
+
w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let a = h0
|
|
81
|
+
let b = h1
|
|
82
|
+
let c = h2
|
|
83
|
+
let d = h3
|
|
84
|
+
let e = h4
|
|
85
|
+
let f = h5
|
|
86
|
+
let g = h6
|
|
87
|
+
let h = h7
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < 64; i++) {
|
|
90
|
+
const bigS1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)
|
|
91
|
+
const ch = (e & f) ^ (~e & g)
|
|
92
|
+
const t1 = (h + bigS1 + ch + K[i]! + w[i]!) >>> 0
|
|
93
|
+
const bigS0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)
|
|
94
|
+
const maj = (a & b) ^ (a & c) ^ (b & c)
|
|
95
|
+
const t2 = (bigS0 + maj) >>> 0
|
|
96
|
+
h = g
|
|
97
|
+
g = f
|
|
98
|
+
f = e
|
|
99
|
+
e = (d + t1) >>> 0
|
|
100
|
+
d = c
|
|
101
|
+
c = b
|
|
102
|
+
b = a
|
|
103
|
+
a = (t1 + t2) >>> 0
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
h0 = (h0 + a) >>> 0
|
|
107
|
+
h1 = (h1 + b) >>> 0
|
|
108
|
+
h2 = (h2 + c) >>> 0
|
|
109
|
+
h3 = (h3 + d) >>> 0
|
|
110
|
+
h4 = (h4 + e) >>> 0
|
|
111
|
+
h5 = (h5 + f) >>> 0
|
|
112
|
+
h6 = (h6 + g) >>> 0
|
|
113
|
+
h7 = (h7 + h) >>> 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const update = (bytes: Uint8Array): void => {
|
|
117
|
+
totalLen += bytes.length
|
|
118
|
+
let off = 0
|
|
119
|
+
|
|
120
|
+
// Top up a partially filled block from a previous slice.
|
|
121
|
+
if (blockLen > 0) {
|
|
122
|
+
const take = Math.min(64 - blockLen, bytes.length)
|
|
123
|
+
block.set(bytes.subarray(0, take), blockLen)
|
|
124
|
+
blockLen += take
|
|
125
|
+
off = take
|
|
126
|
+
if (blockLen === 64) {
|
|
127
|
+
compress()
|
|
128
|
+
blockLen = 0
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Consume whole 64-byte blocks directly from the slice.
|
|
133
|
+
while (bytes.length - off >= 64) {
|
|
134
|
+
block.set(bytes.subarray(off, off + 64), 0)
|
|
135
|
+
compress()
|
|
136
|
+
off += 64
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Stash the trailing remainder for the next slice / finalization.
|
|
140
|
+
if (off < bytes.length) {
|
|
141
|
+
const rest = bytes.subarray(off)
|
|
142
|
+
block.set(rest, 0)
|
|
143
|
+
blockLen = rest.length
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const size = blob.size
|
|
148
|
+
for (let pos = 0; pos < size; pos += chunkSize) {
|
|
149
|
+
if (signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError')
|
|
150
|
+
const end = Math.min(size, pos + chunkSize)
|
|
151
|
+
const buf = await blob.slice(pos, end).arrayBuffer()
|
|
152
|
+
update(new Uint8Array(buf))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Finalize: append 0x80, pad with zeros, then the 64-bit big-endian bit length.
|
|
156
|
+
const lenBits = totalLen * 8
|
|
157
|
+
const hi = Math.floor(lenBits / 0x100000000) >>> 0
|
|
158
|
+
const lo = lenBits >>> 0
|
|
159
|
+
|
|
160
|
+
block[blockLen++] = 0x80
|
|
161
|
+
if (blockLen > 56) {
|
|
162
|
+
while (blockLen < 64) block[blockLen++] = 0
|
|
163
|
+
compress()
|
|
164
|
+
blockLen = 0
|
|
165
|
+
}
|
|
166
|
+
while (blockLen < 56) block[blockLen++] = 0
|
|
167
|
+
block[56] = (hi >>> 24) & 0xff
|
|
168
|
+
block[57] = (hi >>> 16) & 0xff
|
|
169
|
+
block[58] = (hi >>> 8) & 0xff
|
|
170
|
+
block[59] = hi & 0xff
|
|
171
|
+
block[60] = (lo >>> 24) & 0xff
|
|
172
|
+
block[61] = (lo >>> 16) & 0xff
|
|
173
|
+
block[62] = (lo >>> 8) & 0xff
|
|
174
|
+
block[63] = lo & 0xff
|
|
175
|
+
compress()
|
|
176
|
+
|
|
177
|
+
const toHex = (x: number): string => (x >>> 0).toString(16).padStart(8, '0')
|
|
178
|
+
return toHex(h0) + toHex(h1) + toHex(h2) + toHex(h3) + toHex(h4) + toHex(h5) + toHex(h6) + toHex(h7)
|
|
179
|
+
}
|
package/src/core/utils/id.ts
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a unique upload ID.
|
|
3
|
+
*
|
|
4
|
+
* Format: `upload_<unix-ms>_<uuid-v4>`. The UUID is produced by
|
|
5
|
+
* {@link Crypto.randomUUID} on platforms that expose it (Node >= 14.17,
|
|
6
|
+
* all modern browsers in secure contexts). On legacy runtimes we fall
|
|
7
|
+
* back to {@link Crypto.getRandomValues} + manual v4 formatting.
|
|
8
|
+
*
|
|
9
|
+
* Throws if no CSPRNG is reachable; `Math.random()` is deliberately not used,
|
|
10
|
+
* since predictable IDs can collide and leak creation ordering.
|
|
11
|
+
*/
|
|
1
12
|
export function generateId(): string {
|
|
2
|
-
return `upload_${Date.now()}_${
|
|
13
|
+
return `upload_${Date.now()}_${randomUUIDv4()}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function randomUUIDv4(): string {
|
|
17
|
+
const c: Crypto | undefined = (globalThis as { crypto?: Crypto }).crypto
|
|
18
|
+
if (c && typeof c.randomUUID === 'function') {
|
|
19
|
+
return c.randomUUID()
|
|
20
|
+
}
|
|
21
|
+
if (c && typeof c.getRandomValues === 'function') {
|
|
22
|
+
const bytes = new Uint8Array(16)
|
|
23
|
+
c.getRandomValues(bytes)
|
|
24
|
+
// RFC 4122 §4.4 — set version (4) and variant (10xx). `bytes` is fixed at
|
|
25
|
+
// length 16, so every index is in bounds; `?? 0` satisfies the checker.
|
|
26
|
+
const b6 = bytes[6] ?? 0
|
|
27
|
+
const b8 = bytes[8] ?? 0
|
|
28
|
+
bytes[6] = (b6 & 0x0f) | 0x40
|
|
29
|
+
bytes[8] = (b8 & 0x3f) | 0x80
|
|
30
|
+
const hex: string[] = []
|
|
31
|
+
for (let i = 0; i < bytes.length; i++) hex.push((bytes[i] ?? 0).toString(16).padStart(2, '0'))
|
|
32
|
+
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
|
|
33
|
+
}
|
|
34
|
+
throw new Error('[duck-upload] No CSPRNG available: crypto.randomUUID and crypto.getRandomValues both missing')
|
|
3
35
|
}
|
package/src/core/utils/index.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
export { sleep } from './async'
|
|
2
|
+
export {
|
|
3
|
+
DEFAULT_MAX_ATTEMPTS,
|
|
4
|
+
DEFAULT_MAX_CONCURRENT_UPLOADS,
|
|
5
|
+
DEFAULT_MAX_ITEMS,
|
|
6
|
+
DEFAULT_PROGRESS_THROTTLE_MS,
|
|
7
|
+
DEFAULT_RETRY_DELAY_BASE_MS,
|
|
8
|
+
DEFAULT_RETRY_DELAY_MAX_MS,
|
|
9
|
+
} from './constants'
|
|
10
|
+
export type { Emitter } from './emitter'
|
|
11
|
+
export { createTypedEmitter } from './emitter'
|
|
12
|
+
export { computeFingerprint, fingerprintMatches } from './fingerprint'
|
|
13
|
+
export { isRecord, stripDangerousKeys } from './guards'
|
|
14
|
+
export { DEFAULT_HASH_CHUNK_SIZE, hashBlob, sha256HexOfBlob } from './hash'
|
|
15
|
+
export { generateId } from './id'
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspect the first bytes of `bytes` and return the canonical MIME type
|
|
3
|
+
* for the format if recognised, or `null` if unknown.
|
|
4
|
+
*
|
|
5
|
+
* 12 bytes is sufficient for every signature handled here; callers may
|
|
6
|
+
* pass a longer slice but anything beyond byte 11 is ignored.
|
|
7
|
+
*/
|
|
8
|
+
export function sniffMime(bytes: Uint8Array): string | null {
|
|
9
|
+
const n = bytes.length
|
|
10
|
+
|
|
11
|
+
// JPEG: FF D8 FF
|
|
12
|
+
if (n >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
13
|
+
return 'image/jpeg'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
17
|
+
if (
|
|
18
|
+
n >= 8 &&
|
|
19
|
+
bytes[0] === 0x89 &&
|
|
20
|
+
bytes[1] === 0x50 &&
|
|
21
|
+
bytes[2] === 0x4e &&
|
|
22
|
+
bytes[3] === 0x47 &&
|
|
23
|
+
bytes[4] === 0x0d &&
|
|
24
|
+
bytes[5] === 0x0a &&
|
|
25
|
+
bytes[6] === 0x1a &&
|
|
26
|
+
bytes[7] === 0x0a
|
|
27
|
+
) {
|
|
28
|
+
return 'image/png'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// GIF: 47 49 46 38 ("GIF8")
|
|
32
|
+
if (n >= 4 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
|
|
33
|
+
return 'image/gif'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// PDF: 25 50 44 46 ("%PDF")
|
|
37
|
+
if (n >= 4 && bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {
|
|
38
|
+
return 'application/pdf'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// WebP: bytes 0-3 = "RIFF" and bytes 8-11 = "WEBP"
|
|
42
|
+
if (
|
|
43
|
+
n >= 12 &&
|
|
44
|
+
bytes[0] === 0x52 &&
|
|
45
|
+
bytes[1] === 0x49 &&
|
|
46
|
+
bytes[2] === 0x46 &&
|
|
47
|
+
bytes[3] === 0x46 &&
|
|
48
|
+
bytes[8] === 0x57 &&
|
|
49
|
+
bytes[9] === 0x45 &&
|
|
50
|
+
bytes[10] === 0x42 &&
|
|
51
|
+
bytes[11] === 0x50
|
|
52
|
+
) {
|
|
53
|
+
return 'image/webp'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// MP4 / ISO BMFF: bytes 4-7 = "ftyp"
|
|
57
|
+
if (n >= 8 && bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
|
|
58
|
+
return 'video/mp4'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// WebM / Matroska EBML header: 1A 45 DF A3
|
|
62
|
+
if (n >= 4 && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) {
|
|
63
|
+
return 'video/webm'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ZIP-family (DOCX/XLSX/PPTX/APK/JAR/EPUB share this): 50 4B 03 04
|
|
67
|
+
if (n >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04) {
|
|
68
|
+
return 'application/zip'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// MP3: ID3v2 tag (49 44 33) or MPEG frame sync (FF FB / FF F3 / FF F2)
|
|
72
|
+
if (n >= 3 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
|
|
73
|
+
return 'audio/mpeg'
|
|
74
|
+
}
|
|
75
|
+
if (n >= 2 && bytes[0] === 0xff && (bytes[1] === 0xfb || bytes[1] === 0xf3 || bytes[1] === 0xf2)) {
|
|
76
|
+
return 'audio/mpeg'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compare a sniffed MIME type against a claimed one. Returns `true` when
|
|
84
|
+
* they refer to the same format, `false` on conflict. `null` sniff is
|
|
85
|
+
* always treated as a match (unknown formats are not a deny condition).
|
|
86
|
+
*
|
|
87
|
+
* Comparison is loose by design `image/jpg` vs `image/jpeg`, ZIP-based
|
|
88
|
+
* Office formats vs `application/zip`, etc. all need to be tolerated.
|
|
89
|
+
*/
|
|
90
|
+
export function mimeMatches(claimed: string, sniffed: string | null): boolean {
|
|
91
|
+
if (!sniffed) return true
|
|
92
|
+
const c = claimed.toLowerCase().trim()
|
|
93
|
+
const s = sniffed.toLowerCase().trim()
|
|
94
|
+
if (c === s) return true
|
|
95
|
+
if (c === '' || c === 'application/octet-stream') return true
|
|
96
|
+
|
|
97
|
+
// Tolerate ZIP-based document formats the magic bytes only say "this
|
|
98
|
+
// is a ZIP container", which is the truthful low-level answer. Any
|
|
99
|
+
// claimed Office / OpenDocument / JAR / APK / EPUB type is consistent.
|
|
100
|
+
if (s === 'application/zip') {
|
|
101
|
+
if (
|
|
102
|
+
c.startsWith('application/vnd.openxmlformats-officedocument.') ||
|
|
103
|
+
c.startsWith('application/vnd.oasis.opendocument.') ||
|
|
104
|
+
c === 'application/epub+zip' ||
|
|
105
|
+
c === 'application/java-archive' ||
|
|
106
|
+
c === 'application/vnd.android.package-archive'
|
|
107
|
+
) {
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// JPEG common aliases.
|
|
113
|
+
if (s === 'image/jpeg' && (c === 'image/jpg' || c === 'image/pjpeg')) return true
|
|
114
|
+
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Module-level dedup set for one-time-per-session warnings. @internal */
|
|
119
|
+
export const __mimeWarnings = new Set<string>()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export type FilenameRejectReason = 'reserved' | 'too-long' | 'empty' | 'path-sep'
|
|
2
|
+
|
|
3
|
+
export type SanitizeFilenameResult =
|
|
4
|
+
| { safe: true; normalised: string }
|
|
5
|
+
| { safe: false; normalised: string; reason: FilenameRejectReason }
|
|
6
|
+
|
|
7
|
+
// TODO: use regex
|
|
8
|
+
const RESERVED_WINDOWS_NAMES = new Set([
|
|
9
|
+
'CON',
|
|
10
|
+
'PRN',
|
|
11
|
+
'AUX',
|
|
12
|
+
'NUL',
|
|
13
|
+
'COM1',
|
|
14
|
+
'COM2',
|
|
15
|
+
'COM3',
|
|
16
|
+
'COM4',
|
|
17
|
+
'COM5',
|
|
18
|
+
'COM6',
|
|
19
|
+
'COM7',
|
|
20
|
+
'COM8',
|
|
21
|
+
'COM9',
|
|
22
|
+
'LPT1',
|
|
23
|
+
'LPT2',
|
|
24
|
+
'LPT3',
|
|
25
|
+
'LPT4',
|
|
26
|
+
'LPT5',
|
|
27
|
+
'LPT6',
|
|
28
|
+
'LPT7',
|
|
29
|
+
'LPT8',
|
|
30
|
+
'LPT9',
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
function stripControlChars(s: string): string {
|
|
34
|
+
let out = ''
|
|
35
|
+
for (let i = 0; i < s.length; i++) {
|
|
36
|
+
const c = s.charCodeAt(i)
|
|
37
|
+
if (c >= 0x20 && c !== 0x7f) out += s[i]
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalise and validate an untrusted filename. Returns
|
|
44
|
+
* `{ safe: true, normalised }` when the name is safe to forward to a backend,
|
|
45
|
+
* or `{ safe: false, normalised, reason }` with the partial cleanup for
|
|
46
|
+
* diagnostics. Pure and synchronous.
|
|
47
|
+
*/
|
|
48
|
+
export function sanitizeFilename(name: string): SanitizeFilenameResult {
|
|
49
|
+
// NFKC normalise.
|
|
50
|
+
let n = name.normalize('NFKC')
|
|
51
|
+
|
|
52
|
+
// Strip control chars and DEL.
|
|
53
|
+
n = stripControlChars(n)
|
|
54
|
+
|
|
55
|
+
// Strip leading dashes.
|
|
56
|
+
while (n.startsWith('-')) n = n.slice(1)
|
|
57
|
+
|
|
58
|
+
// Strip trailing dots and spaces (Windows behaviour).
|
|
59
|
+
while (n.length > 0 && (n.endsWith('.') || n.endsWith(' '))) n = n.slice(0, -1)
|
|
60
|
+
|
|
61
|
+
// Reject if nothing survived.
|
|
62
|
+
if (n.length === 0) {
|
|
63
|
+
return { safe: false, normalised: n, reason: 'empty' }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Path separators must never reach here.
|
|
67
|
+
if (n.includes('/') || n.includes('\\')) {
|
|
68
|
+
return { safe: false, normalised: n, reason: 'path-sep' }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Length cap.
|
|
72
|
+
if (n.length > 255) {
|
|
73
|
+
return { safe: false, normalised: n, reason: 'too-long' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Reserved Windows device names — match the base (before the first dot),
|
|
77
|
+
// case-insensitive. `CON.txt`, `nul`, `COM1.tar.gz` all reject.
|
|
78
|
+
const dot = n.indexOf('.')
|
|
79
|
+
const base = (dot === -1 ? n : n.slice(0, dot)).toUpperCase()
|
|
80
|
+
if (RESERVED_WINDOWS_NAMES.has(base)) {
|
|
81
|
+
return { safe: false, normalised: n, reason: 'reserved' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { safe: true, normalised: n }
|
|
85
|
+
}
|