@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.
Files changed (136) hide show
  1. package/README.md +170 -193
  2. package/dist/core/index.d.ts +2 -3
  3. package/dist/core/index.js +1 -1
  4. package/dist/core-BVgloTiP.js +10 -0
  5. package/dist/index-DCqyBVcx.d.ts +1441 -0
  6. package/dist/index-DsZHPmJm.d.ts +225 -0
  7. package/dist/index-KROXtLQh.d.ts +281 -0
  8. package/dist/index.d.ts +4 -5
  9. package/dist/index.js +1 -1
  10. package/dist/react/index.d.ts +2 -51
  11. package/dist/react/index.js +1 -1
  12. package/dist/react-euPrDv24.js +1 -0
  13. package/dist/strategies/index.d.ts +2 -56
  14. package/dist/strategies/index.js +1 -1
  15. package/dist/strategies-CUF0s68j.js +1 -0
  16. package/package.json +3 -2
  17. package/src/{__test__ → __tests__}/emitter.test.ts +4 -4
  18. package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
  19. package/src/{__test__ → __tests__}/guards.test.ts +15 -5
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
  23. package/src/__tests__/sanitize-filename.test.ts +109 -0
  24. package/src/__tests__/sleep.test.ts +34 -0
  25. package/src/__tests__/static-analysis.test.ts +145 -0
  26. package/src/{__test__ → __tests__}/store-libs.test.ts +57 -3
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/{__test__ → __tests__}/transport.test.ts +5 -5
  29. package/src/core/__tests__/errors.test.ts +152 -0
  30. package/src/core/client.ts +3 -0
  31. package/src/core/contracts/contracts.types.ts +335 -141
  32. package/src/core/contracts/index.ts +3 -8
  33. package/src/core/contracts/transport/index.ts +3 -2
  34. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  35. package/src/core/contracts/transport/transport.libs.ts +7 -8
  36. package/src/core/contracts/transport/transport.ts +91 -83
  37. package/src/core/contracts/transport/transport.types.ts +71 -35
  38. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  39. package/src/core/engine/engine.libs.ts +35 -0
  40. package/src/core/engine/engine.types.ts +367 -241
  41. package/src/core/engine/index.ts +3 -8
  42. package/src/core/engine/reducer.ts +89 -43
  43. package/src/core/engine/store/handlers/add-file.ts +39 -29
  44. package/src/core/engine/store/handlers/cancel.ts +13 -8
  45. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  46. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  47. package/src/core/engine/store/handlers/finalize.ts +30 -13
  48. package/src/core/engine/store/handlers/index.ts +7 -7
  49. package/src/core/engine/store/handlers/pause.ts +8 -6
  50. package/src/core/engine/store/handlers/run-upload.ts +49 -34
  51. package/src/core/engine/store/index.ts +27 -21
  52. package/src/core/engine/store/store.dispatch.ts +10 -8
  53. package/src/core/engine/store/store.libs.ts +184 -41
  54. package/src/core/engine/store/store.runtime.ts +45 -36
  55. package/src/core/engine/store/store.schedule.ts +27 -21
  56. package/src/core/engine/store/store.types.ts +177 -227
  57. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  58. package/src/core/engine/validation/file.ts +7 -3
  59. package/src/core/engine/validation/index.ts +1 -0
  60. package/src/core/engine/validation/intent.ts +49 -27
  61. package/src/core/engine/validation/list.ts +14 -15
  62. package/src/core/engine/validation/mime.ts +39 -0
  63. package/src/core/errors.ts +271 -0
  64. package/src/core/index.ts +28 -5
  65. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  66. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  67. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  68. package/src/core/persistence/adapters.local.ts +9 -3
  69. package/src/core/persistence/adapters.memory.ts +35 -13
  70. package/src/core/persistence/index.ts +5 -5
  71. package/src/core/persistence/persistence.ts +73 -60
  72. package/src/core/persistence/persistence.types.ts +140 -60
  73. package/src/core/utils/__tests__/guards.test.ts +93 -0
  74. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  75. package/src/core/utils/async.ts +7 -2
  76. package/src/core/utils/constants.ts +3 -9
  77. package/src/core/utils/emitter/emitter.ts +7 -24
  78. package/src/core/utils/emitter/emitter.types.ts +33 -16
  79. package/src/core/utils/emitter/index.ts +2 -74
  80. package/src/core/utils/fingerprint.ts +16 -5
  81. package/src/core/utils/guards.ts +91 -1
  82. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  83. package/src/core/utils/hash/index.ts +126 -0
  84. package/src/core/utils/hash/sha256.ts +179 -0
  85. package/src/core/utils/id.ts +33 -1
  86. package/src/core/utils/index.ts +15 -6
  87. package/src/core/utils/mime-sniff.ts +119 -0
  88. package/src/core/utils/sanitize-filename.ts +85 -0
  89. package/src/core/utils/url-safety.ts +156 -0
  90. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  91. package/src/react/index.ts +5 -2
  92. package/src/react/upload-provider.tsx +68 -53
  93. package/src/react/uploader.types.ts +58 -61
  94. package/src/react/use-dropzone.tsx +226 -0
  95. package/src/react/use-uploader.tsx +98 -91
  96. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  97. package/src/strategies/_shared/retry.ts +79 -0
  98. package/src/strategies/index.ts +9 -15
  99. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  100. package/src/strategies/multipart/index.ts +206 -108
  101. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  102. package/src/strategies/post/index.ts +115 -33
  103. package/src/strategies/put/__tests__/put.test.ts +132 -0
  104. package/src/strategies/put/index.ts +139 -0
  105. package/src/strategies/registry/index.ts +12 -9
  106. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  107. package/src/strategies/tus/index.ts +211 -0
  108. package/dist/async-DxRWu4le.js +0 -1
  109. package/dist/core-CMkrP9DX.js +0 -2
  110. package/dist/index-C38Jmizu.d.ts +0 -1005
  111. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  112. package/src/__test__/effect-pool.test.ts +0 -5
  113. package/src/__test__/id.test.ts +0 -14
  114. package/src/__test__/store-integration.test.ts +0 -5
  115. package/src/core/client/client.ts +0 -50
  116. package/src/core/client/client.types.ts +0 -184
  117. package/src/core/client/config.types.ts +0 -77
  118. package/src/core/client/index.ts +0 -4
  119. package/src/core/client/plugins.types.ts +0 -46
  120. package/src/core/client/retry.types.ts +0 -6
  121. package/src/core/client/uploadClient.ts +0 -19
  122. package/src/core/contracts/backendApi.ts +0 -62
  123. package/src/core/contracts/cursor.types.ts +0 -23
  124. package/src/core/contracts/errors.types.ts +0 -67
  125. package/src/core/contracts/fingerprint.types.ts +0 -18
  126. package/src/core/contracts/intent.types.ts +0 -39
  127. package/src/core/contracts/result.types.ts +0 -11
  128. package/src/core/contracts/strategy.types.ts +0 -45
  129. package/src/core/engine/commands.types.ts +0 -17
  130. package/src/core/engine/event-map.types.ts +0 -45
  131. package/src/core/engine/internal-events.types.ts +0 -202
  132. package/src/core/engine/outcome.types.ts +0 -9
  133. package/src/core/engine/progress.types.ts +0 -15
  134. package/src/core/engine/store/handlers.ts +0 -819
  135. package/src/core/engine/validation.ts +0 -216
  136. /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
+ }
@@ -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()}_${Math.random().toString(36).slice(2)}`
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
  }
@@ -1,6 +1,15 @@
1
- export * from './async'
2
- export * from './constants'
3
- export * from './emitter'
4
- export * from './fingerprint'
5
- export * from './guards'
6
- export * from './id'
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
+ })
@@ -1,2 +1,5 @@
1
- export * from './upload-provider'
2
- export * from './use-uploader'
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'