@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
|
@@ -1,52 +1,74 @@
|
|
|
1
|
+
import { UploadEngineError } from '../../errors'
|
|
1
2
|
import { isRecord } from '../../utils/guards'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
function isFinitePositive(v: unknown): v is number {
|
|
5
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validates the structure and properties of an upload intent returned from the backend.
|
|
10
|
+
*
|
|
11
|
+
* Ensures all required fields are present and valid for the specified strategy.
|
|
12
|
+
*
|
|
13
|
+
* @param intent - The intent payload to validate.
|
|
14
|
+
* @param strategy - The strategy discriminant matching the intent.
|
|
15
|
+
* @returns An `UploadEngineError` if invalid, or `null` if the intent is well-formed.
|
|
16
|
+
*/
|
|
17
|
+
export function validateIntent(intent: unknown, strategy: string): UploadEngineError | null {
|
|
4
18
|
if (!isRecord(intent)) {
|
|
5
|
-
return new
|
|
19
|
+
return new UploadEngineError('validation_failed', { message: 'Invalid intent: must be an object' })
|
|
6
20
|
}
|
|
7
21
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return new Error('Invalid intent: missing or invalid strategy field')
|
|
22
|
+
if (typeof intent['strategy'] !== 'string') {
|
|
23
|
+
return new UploadEngineError('validation_failed', { message: 'Invalid intent: missing or invalid strategy field' })
|
|
11
24
|
}
|
|
12
25
|
|
|
13
|
-
if (intent
|
|
14
|
-
return new
|
|
26
|
+
if (intent['strategy'] !== strategy) {
|
|
27
|
+
return new UploadEngineError('validation_failed', {
|
|
28
|
+
message: `Invalid intent: strategy mismatch (expected ${strategy}, got ${intent['strategy']})`,
|
|
29
|
+
})
|
|
15
30
|
}
|
|
16
31
|
|
|
17
|
-
if (typeof intent
|
|
18
|
-
return new
|
|
32
|
+
if (typeof intent['fileId'] !== 'string' || !intent['fileId']) {
|
|
33
|
+
return new UploadEngineError('validation_failed', { message: 'Invalid intent: missing or invalid fileId' })
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
// Strategy-specific validation
|
|
22
36
|
if (strategy === 'post') {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return new Error('Invalid post intent: missing or invalid url')
|
|
37
|
+
if (typeof intent['url'] !== 'string' || !intent['url']) {
|
|
38
|
+
return new UploadEngineError('validation_failed', { message: 'Invalid post intent: missing or invalid url' })
|
|
26
39
|
}
|
|
27
40
|
try {
|
|
28
|
-
const url = new URL(intent
|
|
41
|
+
const url = new URL(intent['url'] as string)
|
|
29
42
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
30
|
-
return new
|
|
43
|
+
return new UploadEngineError('validation_failed', {
|
|
44
|
+
message: 'Invalid post intent: url must use http or https protocol',
|
|
45
|
+
})
|
|
31
46
|
}
|
|
32
47
|
} catch {
|
|
33
|
-
return new
|
|
48
|
+
return new UploadEngineError('validation_failed', { message: 'Invalid post intent: url is not a valid URL' })
|
|
34
49
|
}
|
|
35
|
-
if (!intent
|
|
36
|
-
return new
|
|
50
|
+
if (!intent['fields'] || typeof intent['fields'] !== 'object') {
|
|
51
|
+
return new UploadEngineError('validation_failed', { message: 'Invalid post intent: missing or invalid fields' })
|
|
37
52
|
}
|
|
38
53
|
} else if (strategy === 'multipart') {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
if (typeof intent['uploadId'] !== 'string' || !intent['uploadId']) {
|
|
55
|
+
return new UploadEngineError('validation_failed', {
|
|
56
|
+
message: 'Invalid multipart intent: missing or invalid uploadId',
|
|
57
|
+
})
|
|
42
58
|
}
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
// NaN-bypass defense: typeof NaN === 'number' and NaN > 0 is false, so a
|
|
60
|
+
// plain `partSize <= 0` check would let NaN through. isFinitePositive rejects it.
|
|
61
|
+
if (!isFinitePositive(intent['partSize'])) {
|
|
62
|
+
return new UploadEngineError('validation_failed', {
|
|
63
|
+
message: 'Invalid multipart intent: missing or invalid partSize',
|
|
64
|
+
})
|
|
45
65
|
}
|
|
46
|
-
// Parts array is optional (can be fetched later)
|
|
47
|
-
if ('parts' in intent && intent
|
|
48
|
-
if (!Array.isArray(intent
|
|
49
|
-
return new
|
|
66
|
+
// Parts array is optional (can be fetched later).
|
|
67
|
+
if ('parts' in intent && intent['parts'] !== undefined) {
|
|
68
|
+
if (!Array.isArray(intent['parts'])) {
|
|
69
|
+
return new UploadEngineError('validation_failed', {
|
|
70
|
+
message: 'Invalid multipart intent: parts must be an array if provided',
|
|
71
|
+
})
|
|
50
72
|
}
|
|
51
73
|
}
|
|
52
74
|
}
|
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../../contracts'
|
|
2
|
+
import type { Engine } from '../engine.types'
|
|
3
3
|
import { validateFile } from './file'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Validates an intent object from the backend.
|
|
7
|
+
* Ensures required fields are present and URLs are valid.
|
|
8
|
+
*
|
|
9
|
+
* @param intent - Intent object to validate
|
|
10
|
+
* @param strategy - Expected strategy type (from the intent itself)
|
|
11
|
+
*
|
|
12
|
+
* @returns {Error | null} Error if validation fails, null if valid
|
|
13
|
+
*/
|
|
5
14
|
export function validateFileList<P extends string>(
|
|
6
15
|
files: File[],
|
|
7
16
|
purpose: P,
|
|
8
|
-
config:
|
|
17
|
+
config: Engine.Config<P>,
|
|
9
18
|
existingCount: number = 0,
|
|
10
|
-
): { valid: File[]; rejected: Array<{ file: File; reason:
|
|
19
|
+
): { valid: File[]; rejected: Array<{ file: File; reason: Contracts.Validation.Rejection }> } {
|
|
11
20
|
const rules = config.validation?.[purpose]
|
|
12
21
|
const valid: File[] = []
|
|
13
|
-
const rejected: Array<{ file: File; reason:
|
|
22
|
+
const rejected: Array<{ file: File; reason: Contracts.Validation.Rejection }> = []
|
|
14
23
|
|
|
15
24
|
// Calculate remaining slots based on maxFiles limit
|
|
16
25
|
const maxFiles = rules?.maxFiles
|
|
@@ -38,13 +47,3 @@ export function validateFileList<P extends string>(
|
|
|
38
47
|
|
|
39
48
|
return { valid, rejected }
|
|
40
49
|
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Validates an intent object from the backend.
|
|
44
|
-
* Ensures required fields are present and URLs are valid.
|
|
45
|
-
*
|
|
46
|
-
* @param intent - Intent object to validate
|
|
47
|
-
* @param strategy - Expected strategy type (from the intent itself)
|
|
48
|
-
*
|
|
49
|
-
* @returns {Error | null} Error if validation fails, null if valid
|
|
50
|
-
*/
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Contracts } from '../../contracts'
|
|
2
|
+
import { __mimeWarnings, mimeMatches, sniffMime } from '../../utils/mime-sniff'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cross-check the claimed `file.type` against the file's magic bytes. Reads the
|
|
6
|
+
* first 16 bytes only (the longest signature {@link sniffMime} handles is 12).
|
|
7
|
+
*
|
|
8
|
+
* @returns A `mime_mismatch` reject reason when `strict` is `true` and the
|
|
9
|
+
* sniff disagrees. Returns `null` (allow) when the sniff is inconclusive, the
|
|
10
|
+
* claim matches, or `strict` is `false` — in the non-strict case a one-time
|
|
11
|
+
* warning is logged per mismatched pair. Empty files resolve to `null`.
|
|
12
|
+
*/
|
|
13
|
+
export async function validateMimeSignature(
|
|
14
|
+
file: File,
|
|
15
|
+
strict: boolean,
|
|
16
|
+
): Promise<Contracts.Validation.Rejection | null> {
|
|
17
|
+
if (file.size === 0) return null
|
|
18
|
+
// 16 bytes covers every recognised signature (longest is 12).
|
|
19
|
+
const head = await file.slice(0, 16).arrayBuffer()
|
|
20
|
+
const sniffed = sniffMime(new Uint8Array(head))
|
|
21
|
+
if (sniffed === null) return null
|
|
22
|
+
const claimed = file.type || ''
|
|
23
|
+
if (mimeMatches(claimed, sniffed)) return null
|
|
24
|
+
|
|
25
|
+
if (strict) {
|
|
26
|
+
return { code: 'mime_mismatch', claimed: claimed || '<empty>', sniffed }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const key = `${claimed || '<empty>'}::${sniffed}`
|
|
30
|
+
if (!__mimeWarnings.has(key)) {
|
|
31
|
+
__mimeWarnings.add(key)
|
|
32
|
+
// Filename is omitted from the warning: it is untrusted input.
|
|
33
|
+
console.warn(
|
|
34
|
+
`[duck-upload] MIME mismatch: claimed "${claimed || '<empty>'}" but bytes look like "${sniffed}". ` +
|
|
35
|
+
'Set `strictMimeMatch: true` to reject these files.',
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import type { Contracts } from './contracts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Structured extra context properties for upload errors.
|
|
5
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
6
|
+
*/
|
|
7
|
+
export type UploadErrorContext = Record<string, unknown>
|
|
8
|
+
|
|
9
|
+
const STATIC_MESSAGES: Record<string, string> = {
|
|
10
|
+
intent_failed: 'upload intent request failed',
|
|
11
|
+
upload_failed: 'upload transfer failed',
|
|
12
|
+
complete_failed: 'upload finalize failed',
|
|
13
|
+
validation_failed: 'file validation failed',
|
|
14
|
+
strategy_missing: 'upload strategy not found',
|
|
15
|
+
aborted: 'upload aborted',
|
|
16
|
+
network: 'network error occurred',
|
|
17
|
+
http: 'http request failed',
|
|
18
|
+
timeout: 'request timed out',
|
|
19
|
+
auth: 'authentication failed',
|
|
20
|
+
rate_limit: 'rate limit exceeded',
|
|
21
|
+
server: 'server error occurred',
|
|
22
|
+
unknown: 'unknown upload error',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Base typed error thrown by the upload engine.
|
|
27
|
+
*
|
|
28
|
+
* Always carries a stable error classification `code` and an immutable, static
|
|
29
|
+
* `message`. Attic strings (like raw input details) live under `context` or subclass-specific fields.
|
|
30
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
31
|
+
*/
|
|
32
|
+
export class UploadEngineError extends Error {
|
|
33
|
+
/** Stable machine-readable error code. */
|
|
34
|
+
readonly code: string
|
|
35
|
+
/** Structured context details. */
|
|
36
|
+
readonly context?: UploadErrorContext
|
|
37
|
+
/** Original error, if any, mapped per ES2022. */
|
|
38
|
+
override readonly cause?: unknown
|
|
39
|
+
/** Flag specifying whether the error recovery system should schedule retries. */
|
|
40
|
+
readonly retryable: boolean
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
code: string,
|
|
44
|
+
init?: {
|
|
45
|
+
message?: string | undefined
|
|
46
|
+
context?: UploadErrorContext | undefined
|
|
47
|
+
cause?: unknown
|
|
48
|
+
retryable?: boolean | undefined
|
|
49
|
+
},
|
|
50
|
+
) {
|
|
51
|
+
const msg = init?.message ?? STATIC_MESSAGES[code] ?? `upload error (${code})`
|
|
52
|
+
super(msg)
|
|
53
|
+
this.name = 'UploadEngineError'
|
|
54
|
+
this.code = code
|
|
55
|
+
this.retryable = init?.retryable ?? false
|
|
56
|
+
if (init?.context) this.context = init.context
|
|
57
|
+
if (init?.cause !== undefined) this.cause = init.cause
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Error thrown when file validation rules fail.
|
|
63
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
64
|
+
*/
|
|
65
|
+
export class UploadValidationError extends UploadEngineError {
|
|
66
|
+
/** Structured rejection reason details. */
|
|
67
|
+
readonly reason: Contracts.Validation.Rejection
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
reason: Contracts.Validation.Rejection,
|
|
71
|
+
message?: string,
|
|
72
|
+
init?: { cause?: unknown; context?: UploadErrorContext },
|
|
73
|
+
) {
|
|
74
|
+
super('validation_failed', {
|
|
75
|
+
message: message ?? `File validation failed: ${reason.code}`,
|
|
76
|
+
retryable: false,
|
|
77
|
+
cause: init?.cause,
|
|
78
|
+
context: init?.context,
|
|
79
|
+
})
|
|
80
|
+
this.name = 'UploadValidationError'
|
|
81
|
+
this.reason = reason
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Error thrown when an upload strategy identifier is not registered.
|
|
87
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
88
|
+
*/
|
|
89
|
+
export class UploadStrategyMissingError extends UploadEngineError {
|
|
90
|
+
/** Unregistered strategy identifier. */
|
|
91
|
+
readonly strategy: string
|
|
92
|
+
|
|
93
|
+
constructor(strategy: string, message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
|
|
94
|
+
super('strategy_missing', {
|
|
95
|
+
message: message ?? `Upload strategy "${strategy}" is not registered`,
|
|
96
|
+
retryable: false,
|
|
97
|
+
cause: init?.cause,
|
|
98
|
+
context: init?.context,
|
|
99
|
+
})
|
|
100
|
+
this.name = 'UploadStrategyMissingError'
|
|
101
|
+
this.strategy = strategy
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Error thrown when an upload is aborted (paused or canceled).
|
|
107
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
108
|
+
*/
|
|
109
|
+
export class UploadAbortError extends UploadEngineError {
|
|
110
|
+
/** Reason context for the abort. */
|
|
111
|
+
readonly reason: 'pause' | 'cancel' | 'unknown'
|
|
112
|
+
|
|
113
|
+
constructor(
|
|
114
|
+
reason: 'pause' | 'cancel' | 'unknown',
|
|
115
|
+
message?: string,
|
|
116
|
+
init?: { cause?: unknown; context?: UploadErrorContext },
|
|
117
|
+
) {
|
|
118
|
+
super('aborted', {
|
|
119
|
+
message: message ?? 'Upload aborted',
|
|
120
|
+
retryable: false,
|
|
121
|
+
cause: init?.cause,
|
|
122
|
+
context: init?.context,
|
|
123
|
+
})
|
|
124
|
+
this.name = 'UploadAbortError'
|
|
125
|
+
this.reason = reason
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Error thrown on transport-level connection or fetch failures.
|
|
131
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
132
|
+
*/
|
|
133
|
+
export class UploadNetworkError extends UploadEngineError {
|
|
134
|
+
constructor(message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
|
|
135
|
+
super('network', {
|
|
136
|
+
message: message ?? 'Network error occurred',
|
|
137
|
+
retryable: true,
|
|
138
|
+
cause: init?.cause,
|
|
139
|
+
context: init?.context,
|
|
140
|
+
})
|
|
141
|
+
this.name = 'UploadNetworkError'
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Error thrown when a non-2xx HTTP status response is returned.
|
|
147
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
148
|
+
*/
|
|
149
|
+
export class UploadHttpError extends UploadEngineError {
|
|
150
|
+
/** HTTP response status code. */
|
|
151
|
+
readonly status: number
|
|
152
|
+
/** HTTP response status text message. */
|
|
153
|
+
readonly statusText?: string
|
|
154
|
+
|
|
155
|
+
constructor(
|
|
156
|
+
status: number,
|
|
157
|
+
message?: string,
|
|
158
|
+
init?: {
|
|
159
|
+
statusText?: string | undefined
|
|
160
|
+
cause?: unknown
|
|
161
|
+
context?: UploadErrorContext | undefined
|
|
162
|
+
retryable?: boolean | undefined
|
|
163
|
+
},
|
|
164
|
+
) {
|
|
165
|
+
const isRetryable = init?.retryable ?? (status >= 500 || status === 429)
|
|
166
|
+
super('http', {
|
|
167
|
+
message: message ?? `HTTP request failed with status ${status}`,
|
|
168
|
+
retryable: isRetryable,
|
|
169
|
+
cause: init?.cause,
|
|
170
|
+
context: init?.context,
|
|
171
|
+
})
|
|
172
|
+
this.name = 'UploadHttpError'
|
|
173
|
+
this.status = status
|
|
174
|
+
if (init?.statusText) this.statusText = init.statusText
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Error thrown when a timeout occurs during network operations.
|
|
180
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
181
|
+
*/
|
|
182
|
+
export class UploadTimeoutError extends UploadEngineError {
|
|
183
|
+
constructor(message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
|
|
184
|
+
super('timeout', {
|
|
185
|
+
message: message ?? 'Request timed out',
|
|
186
|
+
retryable: true,
|
|
187
|
+
cause: init?.cause,
|
|
188
|
+
context: init?.context,
|
|
189
|
+
})
|
|
190
|
+
this.name = 'UploadTimeoutError'
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Error thrown on authentication / authorization failures.
|
|
196
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
197
|
+
*/
|
|
198
|
+
export class UploadAuthError extends UploadEngineError {
|
|
199
|
+
constructor(message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
|
|
200
|
+
super('auth', {
|
|
201
|
+
message: message ?? 'Authentication failed',
|
|
202
|
+
retryable: false,
|
|
203
|
+
cause: init?.cause,
|
|
204
|
+
context: init?.context,
|
|
205
|
+
})
|
|
206
|
+
this.name = 'UploadAuthError'
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Error thrown when the endpoint indicates rate limits are exceeded.
|
|
212
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
213
|
+
*/
|
|
214
|
+
export class UploadRateLimitError extends UploadEngineError {
|
|
215
|
+
/** Optional delay value recommended by the server before retry. */
|
|
216
|
+
readonly retryAfterMs?: number
|
|
217
|
+
|
|
218
|
+
constructor(message?: string, init?: { retryAfterMs?: number; cause?: unknown; context?: UploadErrorContext }) {
|
|
219
|
+
super('rate_limit', {
|
|
220
|
+
message: message ?? 'Rate limit exceeded',
|
|
221
|
+
retryable: true,
|
|
222
|
+
cause: init?.cause,
|
|
223
|
+
context: init?.context,
|
|
224
|
+
})
|
|
225
|
+
this.name = 'UploadRateLimitError'
|
|
226
|
+
if (init?.retryAfterMs !== undefined) this.retryAfterMs = init.retryAfterMs
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Error thrown when custom backend/server validation errors are reported.
|
|
232
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
233
|
+
*/
|
|
234
|
+
export class UploadServerError extends UploadEngineError {
|
|
235
|
+
/** Server-defined diagnostic code. */
|
|
236
|
+
readonly serverCode?: string
|
|
237
|
+
|
|
238
|
+
constructor(message?: string, init?: { serverCode?: string; cause?: unknown; context?: UploadErrorContext }) {
|
|
239
|
+
super('server', {
|
|
240
|
+
message: message ?? 'Server error occurred',
|
|
241
|
+
retryable: true,
|
|
242
|
+
cause: init?.cause,
|
|
243
|
+
context: init?.context,
|
|
244
|
+
})
|
|
245
|
+
this.name = 'UploadServerError'
|
|
246
|
+
if (init?.serverCode) this.serverCode = init.serverCode
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Fallback error class for unclassified or legacy throws.
|
|
252
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
253
|
+
*/
|
|
254
|
+
export class UploadUnknownError extends UploadEngineError {
|
|
255
|
+
constructor(message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
|
|
256
|
+
super('unknown', {
|
|
257
|
+
message: message ?? 'Unknown upload error',
|
|
258
|
+
retryable: false,
|
|
259
|
+
cause: init?.cause,
|
|
260
|
+
context: init?.context,
|
|
261
|
+
})
|
|
262
|
+
this.name = 'UploadUnknownError'
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Runtime error stored on items and passed through engine events.
|
|
268
|
+
* Plain contract shapes and {@link UploadEngineError} subclasses are both valid.
|
|
269
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
270
|
+
*/
|
|
271
|
+
export type UploadError = Contracts.Errors.Error | UploadEngineError
|
package/src/core/index.ts
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
export
|
|
5
|
-
export
|
|
1
|
+
export { resolveUploadConfig } from './client'
|
|
2
|
+
export type { Contracts, Transport } from './contracts'
|
|
3
|
+
export { createFetchTransport, createXHRTransport } from './contracts'
|
|
4
|
+
export type { Store } from './engine'
|
|
5
|
+
export { createUploadStore, Engine } from './engine'
|
|
6
|
+
export type { UploadError, UploadErrorContext } from './errors'
|
|
7
|
+
export {
|
|
8
|
+
UploadAbortError,
|
|
9
|
+
UploadAuthError,
|
|
10
|
+
UploadEngineError,
|
|
11
|
+
UploadHttpError,
|
|
12
|
+
UploadNetworkError,
|
|
13
|
+
UploadRateLimitError,
|
|
14
|
+
UploadServerError,
|
|
15
|
+
UploadStrategyMissingError,
|
|
16
|
+
UploadTimeoutError,
|
|
17
|
+
UploadUnknownError,
|
|
18
|
+
UploadValidationError,
|
|
19
|
+
} from './errors'
|
|
20
|
+
export type { UploadPersistence } from './persistence'
|
|
21
|
+
export {
|
|
22
|
+
createMemoryAdapter,
|
|
23
|
+
deserializeSnapshot,
|
|
24
|
+
IndexedDBAdapter,
|
|
25
|
+
LocalStorageAdapter,
|
|
26
|
+
MemoryAdapter,
|
|
27
|
+
serializeSnapshot,
|
|
28
|
+
} from './persistence'
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NaN-bypass defense for the persistence deserializer. A persisted
|
|
3
|
+
* `size: NaN`, `lastModified: NaN`, `pct: NaN`, or negative byte count
|
|
4
|
+
* would land in the deserialized state and break downstream math
|
|
5
|
+
* (`uploadedBytes / totalBytes` → NaN, comparisons silently false).
|
|
6
|
+
* Every numeric field must pass `Number.isFinite` + non-negative.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'vitest'
|
|
10
|
+
import { deserializeSnapshot } from '../persistence'
|
|
11
|
+
|
|
12
|
+
function makeOpts(strategy = 'post') {
|
|
13
|
+
return {
|
|
14
|
+
isPurpose: (v: string): v is string => typeof v === 'string',
|
|
15
|
+
isIntent: (v: unknown): v is { strategy: string; fileId: string } =>
|
|
16
|
+
typeof v === 'object' && v !== null && 'strategy' in v,
|
|
17
|
+
hasStrategy: (v: string) => v === strategy,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const okItem = {
|
|
22
|
+
id: 'u1',
|
|
23
|
+
purpose: 'avatar',
|
|
24
|
+
status: 'paused',
|
|
25
|
+
file: { name: 'a.txt', size: 100, type: 'text/plain', lastModified: 1700000000 },
|
|
26
|
+
intent: { strategy: 'post', fileId: 'f' },
|
|
27
|
+
cursor: { strategy: 'post' },
|
|
28
|
+
progress: { uploadedBytes: 50, totalBytes: 100, pct: 50 },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('deserializeSnapshot - NaN-bypass defense', () => {
|
|
32
|
+
test('NaN version rejects snapshot', () => {
|
|
33
|
+
const raw = { version: Number.NaN, createdAt: 1, items: { u1: okItem } }
|
|
34
|
+
expect(deserializeSnapshot(raw, makeOpts())).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('Infinity createdAt rejects snapshot', () => {
|
|
38
|
+
const raw = { version: 1, createdAt: Number.POSITIVE_INFINITY, items: { u1: okItem } }
|
|
39
|
+
expect(deserializeSnapshot(raw, makeOpts())).toBeNull()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('NaN file.size drops the item (other items still load)', () => {
|
|
43
|
+
const raw = {
|
|
44
|
+
version: 1,
|
|
45
|
+
createdAt: 1,
|
|
46
|
+
items: {
|
|
47
|
+
u1: { ...okItem, file: { ...okItem.file, size: Number.NaN } },
|
|
48
|
+
u2: { ...okItem, id: 'u2' },
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
const state = deserializeSnapshot(raw, makeOpts())
|
|
52
|
+
expect(state).not.toBeNull()
|
|
53
|
+
expect(state?.items.has('u1')).toBe(false)
|
|
54
|
+
expect(state?.items.has('u2')).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('negative size drops the item', () => {
|
|
58
|
+
const raw = {
|
|
59
|
+
version: 1,
|
|
60
|
+
createdAt: 1,
|
|
61
|
+
items: { u1: { ...okItem, file: { ...okItem.file, size: -1 } } },
|
|
62
|
+
}
|
|
63
|
+
const state = deserializeSnapshot(raw, makeOpts())
|
|
64
|
+
expect(state?.items.has('u1')).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('NaN lastModified drops the item', () => {
|
|
68
|
+
const raw = {
|
|
69
|
+
version: 1,
|
|
70
|
+
createdAt: 1,
|
|
71
|
+
items: { u1: { ...okItem, file: { ...okItem.file, lastModified: Number.NaN } } },
|
|
72
|
+
}
|
|
73
|
+
const state = deserializeSnapshot(raw, makeOpts())
|
|
74
|
+
expect(state?.items.has('u1')).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('NaN progress.uploadedBytes drops progress (item still loads with zero)', () => {
|
|
78
|
+
const raw = {
|
|
79
|
+
version: 1,
|
|
80
|
+
createdAt: 1,
|
|
81
|
+
items: {
|
|
82
|
+
u1: { ...okItem, progress: { ...okItem.progress, uploadedBytes: Number.NaN } },
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
const state = deserializeSnapshot(raw, makeOpts())
|
|
86
|
+
expect(state?.items.has('u1')).toBe(true)
|
|
87
|
+
const item = state?.items.get('u1') as { progress?: { uploadedBytes: number } } | undefined
|
|
88
|
+
expect(item?.progress?.uploadedBytes).toBe(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('empty MIME type is allowed (browsers may return empty for unknown)', () => {
|
|
92
|
+
const raw = {
|
|
93
|
+
version: 1,
|
|
94
|
+
createdAt: 1,
|
|
95
|
+
items: { u1: { ...okItem, file: { ...okItem.file, type: '' } } },
|
|
96
|
+
}
|
|
97
|
+
const state = deserializeSnapshot(raw, makeOpts())
|
|
98
|
+
expect(state?.items.has('u1')).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defense against prototype-pollution via persisted snapshots. Round-trip
|
|
3
|
+
* tests live in `src/__test__/persistence.test.ts`; this file is scoped to
|
|
4
|
+
* security regressions.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
|
7
|
+
import { isRecord } from '../../utils/guards'
|
|
8
|
+
import { LocalStorageAdapter } from '../adapters.local'
|
|
9
|
+
|
|
10
|
+
type LocalStorageStub = {
|
|
11
|
+
store: Record<string, string>
|
|
12
|
+
getItem: (key: string) => string | null
|
|
13
|
+
setItem: (key: string, value: string) => void
|
|
14
|
+
removeItem: (key: string) => void
|
|
15
|
+
clear: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function installLocalStorageStub(): LocalStorageStub {
|
|
19
|
+
const stub: LocalStorageStub = {
|
|
20
|
+
store: {},
|
|
21
|
+
getItem(key) {
|
|
22
|
+
return Object.prototype.hasOwnProperty.call(this.store, key) ? (this.store[key] ?? null) : null
|
|
23
|
+
},
|
|
24
|
+
setItem(key, value) {
|
|
25
|
+
this.store[key] = String(value)
|
|
26
|
+
},
|
|
27
|
+
removeItem(key) {
|
|
28
|
+
delete this.store[key]
|
|
29
|
+
},
|
|
30
|
+
clear() {
|
|
31
|
+
this.store = {}
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
;(globalThis as unknown as { localStorage: LocalStorageStub }).localStorage = stub
|
|
35
|
+
return stub
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('LocalStorageAdapter prototype-pollution defense', () => {
|
|
39
|
+
let stub: LocalStorageStub
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
stub = installLocalStorageStub()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
// Defensive cleanup in case a test forgot to.
|
|
47
|
+
delete (Object.prototype as Record<string, unknown>)['polluted']
|
|
48
|
+
delete (Object.prototype as Record<string, unknown>)['rce']
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('a {"__proto__":{"polluted":true}} payload does NOT pollute Object.prototype after load', async () => {
|
|
52
|
+
stub.store['snap'] = '{"__proto__":{"polluted":true}}'
|
|
53
|
+
const loaded = await LocalStorageAdapter.load('snap')
|
|
54
|
+
expect(loaded).not.toBeNull()
|
|
55
|
+
expect(({} as Record<string, unknown>)['polluted']).toBeUndefined()
|
|
56
|
+
expect(Object.prototype.hasOwnProperty.call(loaded as object, '__proto__')).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('a {"constructor":{"prototype":{"polluted":true}}} payload does NOT pollute', async () => {
|
|
60
|
+
stub.store['snap'] = '{"constructor":{"prototype":{"rce":true}}}'
|
|
61
|
+
const loaded = await LocalStorageAdapter.load('snap')
|
|
62
|
+
expect(loaded).not.toBeNull()
|
|
63
|
+
expect(({} as Record<string, unknown>)['rce']).toBeUndefined()
|
|
64
|
+
expect(Object.prototype.hasOwnProperty.call(loaded as object, 'constructor')).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('array-shaped persisted value is rejected by isRecord (no spread into state)', async () => {
|
|
68
|
+
stub.store['snap'] = '[1,2,3]'
|
|
69
|
+
const loaded = await LocalStorageAdapter.load('snap')
|
|
70
|
+
expect(Array.isArray(loaded)).toBe(true)
|
|
71
|
+
expect(isRecord(loaded)).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('happy path: a plain-object snapshot hydrates fine', async () => {
|
|
75
|
+
stub.store['snap'] = JSON.stringify({ version: 1, createdAt: 100, items: { x: { id: 'x', v: 1 } } })
|
|
76
|
+
const loaded = (await LocalStorageAdapter.load('snap')) as Record<string, unknown>
|
|
77
|
+
expect(loaded).toEqual({ version: 1, createdAt: 100, items: { x: { id: 'x', v: 1 } } })
|
|
78
|
+
expect(isRecord(loaded)).toBe(true)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('malformed JSON returns null', async () => {
|
|
82
|
+
stub.store['snap'] = '{not-json'
|
|
83
|
+
expect(await LocalStorageAdapter.load('snap')).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
})
|