@gentleduck/upload 0.2.6 → 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 +170 -193
- 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 -51
- 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/{__test__ → __tests__}/emitter.test.ts +4 -4
- package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
- package/src/{__test__ → __tests__}/guards.test.ts +15 -5
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
- 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/{__test__ → __tests__}/store-libs.test.ts +57 -3
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/{__test__ → __tests__}/transport.test.ts +5 -5
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +335 -141
- 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 +7 -8
- package/src/core/contracts/transport/transport.ts +91 -83
- 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 +367 -241
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +89 -43
- package/src/core/engine/store/handlers/add-file.ts +39 -29
- 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 +49 -34
- 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 +184 -41
- package/src/core/engine/store/store.runtime.ts +45 -36
- 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 +7 -3
- 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 +7 -24
- 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 +68 -53
- package/src/react/uploader.types.ts +58 -61
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +98 -91
- 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 +206 -108
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +115 -33
- 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-CMkrP9DX.js +0 -2
- package/dist/index-C38Jmizu.d.ts +0 -1005
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/__test__/effect-pool.test.ts +0 -5
- package/src/__test__/id.test.ts +0 -14
- package/src/__test__/store-integration.test.ts +0 -5
- package/src/core/client/client.ts +0 -50
- package/src/core/client/client.types.ts +0 -184
- 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 -202
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
- package/src/core/engine/store/handlers.ts +0 -819
- package/src/core/engine/validation.ts +0 -216
- /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF guards for backend-supplied upload URLs.
|
|
3
|
+
*
|
|
4
|
+
* Every URL that arrives from a non-trusted backend (presigned POST,
|
|
5
|
+
* presigned PUT, multipart part URLs) must pass through
|
|
6
|
+
* `validateUploadUrl` before it reaches the transport. The guard
|
|
7
|
+
* blocks:
|
|
8
|
+
*
|
|
9
|
+
* - Empty / non-string / malformed URLs
|
|
10
|
+
* - `..` path-traversal segments (URL parser would normalize them away)
|
|
11
|
+
* - Non-http(s) protocols (`file:`, `javascript:`, `data:`)
|
|
12
|
+
* - IP literals pointing at loopback / RFC1918 / link-local /
|
|
13
|
+
* cloud-metadata / CGNAT / multicast / broadcast addresses
|
|
14
|
+
* - IPv6 embedded IPv4 (mapped, compat, 6to4, NAT64) carrying any of the above
|
|
15
|
+
*
|
|
16
|
+
* DNS rebinding is out of scope for a client-side check.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
function hexTailToDottedQuad(tail: string): string | null {
|
|
20
|
+
const m = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(tail)
|
|
21
|
+
if (!m || m[1] === undefined || m[2] === undefined) return null
|
|
22
|
+
const hi = parseInt(m[1], 16)
|
|
23
|
+
const lo = parseInt(m[2], 16)
|
|
24
|
+
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return null
|
|
25
|
+
if (hi < 0 || hi > 0xffff || lo < 0 || lo > 0xffff) return null
|
|
26
|
+
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns true when `host` is a literal IP address in one of the
|
|
31
|
+
* private / loopback / cloud-metadata ranges. Hostnames that aren't IP
|
|
32
|
+
* literals return false.
|
|
33
|
+
*/
|
|
34
|
+
export function isPrivateHost(host: string): boolean {
|
|
35
|
+
let h = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
|
|
36
|
+
if (h.endsWith('.')) h = h.slice(0, -1)
|
|
37
|
+
|
|
38
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
|
|
39
|
+
if (v4) {
|
|
40
|
+
const a = Number(v4[1])
|
|
41
|
+
const b = Number(v4[2])
|
|
42
|
+
const c = Number(v4[3])
|
|
43
|
+
const d = Number(v4[4])
|
|
44
|
+
if (a > 255 || b > 255 || c > 255 || d > 255) return false
|
|
45
|
+
if (a === 127) return true // 127.0.0.0/8 loopback
|
|
46
|
+
if (a === 10) return true // 10.0.0.0/8 RFC1918
|
|
47
|
+
if (a === 192 && b === 168) return true // 192.168.0.0/16 RFC1918
|
|
48
|
+
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12 RFC1918
|
|
49
|
+
if (a === 169 && b === 254) return true // 169.254.0.0/16 link-local + AWS metadata
|
|
50
|
+
if (a === 0) return true // 0.0.0.0/8 this-network
|
|
51
|
+
if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 CGNAT
|
|
52
|
+
if (a >= 224 && a <= 239) return true // 224.0.0.0/4 multicast
|
|
53
|
+
if (a >= 240) return true // 240.0.0.0/4 reserved + broadcast
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (h.includes(':')) {
|
|
58
|
+
const lower = h.toLowerCase()
|
|
59
|
+
if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') return true
|
|
60
|
+
if (lower === '::' || lower === '0:0:0:0:0:0:0:0') return true
|
|
61
|
+
if (/^f[cd][0-9a-f]{0,2}:/.test(lower)) return true // fc00::/7
|
|
62
|
+
if (/^fe[89ab][0-9a-f]?:/.test(lower)) return true // fe80::/10
|
|
63
|
+
if (/^ff[0-9a-f]{0,2}:/.test(lower)) return true // ff00::/8 multicast
|
|
64
|
+
|
|
65
|
+
let mappedTail: string | null = null
|
|
66
|
+
if (lower.startsWith('::ffff:')) mappedTail = lower.slice(7)
|
|
67
|
+
else if (lower.startsWith('0:0:0:0:0:ffff:')) mappedTail = lower.slice(15)
|
|
68
|
+
if (mappedTail !== null) {
|
|
69
|
+
if (mappedTail.includes('.')) return isPrivateHost(mappedTail)
|
|
70
|
+
const dotted = hexTailToDottedQuad(mappedTail)
|
|
71
|
+
if (dotted) return isPrivateHost(dotted)
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (lower.startsWith('::') && lower.includes('.')) {
|
|
76
|
+
const tail = lower.slice(2)
|
|
77
|
+
if (/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(tail)) return isPrivateHost(tail)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (lower.startsWith('2002:')) {
|
|
81
|
+
const m = /^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4})(?::|$)/.exec(lower)
|
|
82
|
+
if (m) {
|
|
83
|
+
const dotted = hexTailToDottedQuad(`${m[1]}:${m[2]}`)
|
|
84
|
+
if (dotted) return isPrivateHost(dotted)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (lower.startsWith('64:ff9b:') || lower.startsWith('0064:ff9b:')) {
|
|
89
|
+
const tail = lower.startsWith('0064:') ? lower.slice(10) : lower.slice(8)
|
|
90
|
+
if (tail.includes('.')) {
|
|
91
|
+
const v4match = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(tail)
|
|
92
|
+
if (v4match?.[1] !== undefined) return isPrivateHost(v4match[1])
|
|
93
|
+
}
|
|
94
|
+
const groups = tail.split(':').filter((g) => g.length > 0)
|
|
95
|
+
if (groups.length >= 2) {
|
|
96
|
+
const dotted = hexTailToDottedQuad(`${groups[groups.length - 2]}:${groups[groups.length - 1]}`)
|
|
97
|
+
if (dotted) return isPrivateHost(dotted)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Throw-on-reject URL guard. Use from any strategy whose backend may
|
|
109
|
+
* return adversarial / compromised URLs. `caller` is a short label
|
|
110
|
+
* ('multipart.signPart', 'post.intent', etc.) included in error messages
|
|
111
|
+
* so logs name the source.
|
|
112
|
+
*/
|
|
113
|
+
export function validateUploadUrl(
|
|
114
|
+
rawUrl: string,
|
|
115
|
+
caller: string,
|
|
116
|
+
opts: { allowedHosts?: string[]; allowPrivateHosts?: boolean } = {},
|
|
117
|
+
): void {
|
|
118
|
+
if (typeof rawUrl !== 'string' || rawUrl.length === 0) {
|
|
119
|
+
throw new Error(`${caller} returned an invalid URL (empty or non-string)`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Cap raw URL length. Real presigned URLs are well under 4 KB; anything
|
|
123
|
+
// larger is either a server bug or an attacker probing the parser.
|
|
124
|
+
if (rawUrl.length > 8192) {
|
|
125
|
+
throw new Error(`${caller} URL exceeds 8192-byte length cap`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (rawUrl.includes('..')) {
|
|
129
|
+
throw new Error(`${caller} URL contains forbidden ".." segment`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let parsed: URL
|
|
133
|
+
try {
|
|
134
|
+
parsed = new URL(rawUrl)
|
|
135
|
+
} catch {
|
|
136
|
+
throw new Error(`${caller} returned a malformed URL`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
140
|
+
throw new Error(`${caller} URL has forbidden protocol "${parsed.protocol}"`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (opts.allowedHosts && opts.allowedHosts.length > 0) {
|
|
144
|
+
const host = parsed.host.toLowerCase()
|
|
145
|
+
const list = opts.allowedHosts.map((h) => h.toLowerCase())
|
|
146
|
+
if (!list.includes(host)) {
|
|
147
|
+
throw new Error(`${caller} URL host is not in the configured allow-list`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!opts.allowPrivateHosts) {
|
|
152
|
+
if (isPrivateHost(parsed.hostname)) {
|
|
153
|
+
throw new Error(`${caller} URL points to a private/loopback host`)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { fileMatchesAccept, matchesAcceptToken, selectFiles } from '../use-dropzone'
|
|
3
|
+
|
|
4
|
+
function f(name: string, type = ''): File {
|
|
5
|
+
return new File(['x'], name, { type })
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('matchesAcceptToken', () => {
|
|
9
|
+
test('extension token (case-insensitive)', () => {
|
|
10
|
+
expect(matchesAcceptToken(f('photo.PNG'), '.png')).toBe(true)
|
|
11
|
+
expect(matchesAcceptToken(f('doc.pdf'), '.png')).toBe(false)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('exact MIME token', () => {
|
|
15
|
+
expect(matchesAcceptToken(f('a', 'image/png'), 'image/png')).toBe(true)
|
|
16
|
+
expect(matchesAcceptToken(f('a', 'image/jpeg'), 'image/png')).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('MIME wildcard token', () => {
|
|
20
|
+
expect(matchesAcceptToken(f('a', 'image/jpeg'), 'image/*')).toBe(true)
|
|
21
|
+
expect(matchesAcceptToken(f('a', 'video/mp4'), 'image/*')).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('empty/whitespace token never matches', () => {
|
|
25
|
+
expect(matchesAcceptToken(f('a', 'image/png'), ' ')).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('fileMatchesAccept', () => {
|
|
30
|
+
test('absent or empty accept passes everything', () => {
|
|
31
|
+
expect(fileMatchesAccept(f('a', 'application/zip'))).toBe(true)
|
|
32
|
+
expect(fileMatchesAccept(f('a', 'application/zip'), '')).toBe(true)
|
|
33
|
+
expect(fileMatchesAccept(f('a', 'application/zip'), ' , ')).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('matches when any token matches', () => {
|
|
37
|
+
expect(fileMatchesAccept(f('a.pdf', 'application/pdf'), 'image/*,.pdf')).toBe(true)
|
|
38
|
+
expect(fileMatchesAccept(f('a.png', 'image/png'), 'image/*,.pdf')).toBe(true)
|
|
39
|
+
expect(fileMatchesAccept(f('a.txt', 'text/plain'), 'image/*,.pdf')).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('selectFiles', () => {
|
|
44
|
+
const files = [f('a.png', 'image/png'), f('b.txt', 'text/plain'), f('c.jpg', 'image/jpeg')]
|
|
45
|
+
|
|
46
|
+
test('filters by accept', () => {
|
|
47
|
+
const out = selectFiles(files, { accept: 'image/*' })
|
|
48
|
+
expect(out.map((x) => x.name)).toEqual(['a.png', 'c.jpg'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('collapses to one when multiple is false', () => {
|
|
52
|
+
const out = selectFiles(files, { accept: 'image/*', multiple: false })
|
|
53
|
+
expect(out.map((x) => x.name)).toEqual(['a.png'])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('keeps all when multiple omitted (defaults to many)', () => {
|
|
57
|
+
const out = selectFiles(files, {})
|
|
58
|
+
expect(out).toHaveLength(3)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('empty when nothing matches', () => {
|
|
62
|
+
expect(selectFiles(files, { accept: 'video/*' })).toEqual([])
|
|
63
|
+
})
|
|
64
|
+
})
|
package/src/react/index.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
1
|
+
export { isUploadStore, UploadProvider, useUploadStore } from './upload-provider'
|
|
2
|
+
export type { Uploader } from './uploader.types'
|
|
3
|
+
export type { UseDropzoneOptions, UseDropzoneReturn } from './use-dropzone'
|
|
4
|
+
export { fileMatchesAccept, matchesAcceptToken, selectFiles, useDropzone } from './use-dropzone'
|
|
5
|
+
export { createUploadFactory, useUploader, useUploaderActions } from './use-uploader'
|