@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.
Files changed (129) hide show
  1. package/README.md +297 -0
  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 -90
  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/__tests__/emitter.test.ts +85 -0
  18. package/src/__tests__/fingerprint.test.ts +49 -0
  19. package/src/__tests__/guards.test.ts +40 -0
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/__tests__/persistence.test.ts +191 -0
  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/__tests__/store-libs.test.ts +192 -0
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/__tests__/transport-libs.test.ts +42 -0
  29. package/src/__tests__/transport.test.ts +182 -0
  30. package/src/core/__tests__/errors.test.ts +152 -0
  31. package/src/core/client.ts +3 -0
  32. package/src/core/contracts/contracts.types.ts +373 -0
  33. package/src/core/contracts/index.ts +3 -8
  34. package/src/core/contracts/transport/index.ts +3 -2
  35. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  36. package/src/core/contracts/transport/transport.libs.ts +13 -39
  37. package/src/core/contracts/transport/transport.ts +96 -90
  38. package/src/core/contracts/transport/transport.types.ts +71 -35
  39. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  40. package/src/core/engine/engine.libs.ts +35 -0
  41. package/src/core/engine/engine.types.ts +490 -0
  42. package/src/core/engine/index.ts +3 -8
  43. package/src/core/engine/reducer.ts +93 -53
  44. package/src/core/engine/store/handlers/add-file.ts +43 -48
  45. package/src/core/engine/store/handlers/cancel.ts +13 -8
  46. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  47. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  48. package/src/core/engine/store/handlers/finalize.ts +30 -13
  49. package/src/core/engine/store/handlers/index.ts +7 -7
  50. package/src/core/engine/store/handlers/pause.ts +8 -6
  51. package/src/core/engine/store/handlers/run-upload.ts +54 -39
  52. package/src/core/engine/store/index.ts +27 -21
  53. package/src/core/engine/store/store.dispatch.ts +10 -8
  54. package/src/core/engine/store/store.libs.ts +197 -94
  55. package/src/core/engine/store/store.runtime.ts +52 -53
  56. package/src/core/engine/store/store.schedule.ts +27 -21
  57. package/src/core/engine/store/store.types.ts +177 -227
  58. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  59. package/src/core/engine/validation/file.ts +8 -4
  60. package/src/core/engine/validation/index.ts +1 -0
  61. package/src/core/engine/validation/intent.ts +49 -27
  62. package/src/core/engine/validation/list.ts +14 -15
  63. package/src/core/engine/validation/mime.ts +39 -0
  64. package/src/core/errors.ts +271 -0
  65. package/src/core/index.ts +28 -5
  66. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  67. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  68. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  69. package/src/core/persistence/adapters.local.ts +9 -3
  70. package/src/core/persistence/adapters.memory.ts +35 -13
  71. package/src/core/persistence/index.ts +5 -5
  72. package/src/core/persistence/persistence.ts +73 -60
  73. package/src/core/persistence/persistence.types.ts +140 -60
  74. package/src/core/utils/__tests__/guards.test.ts +93 -0
  75. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  76. package/src/core/utils/async.ts +7 -2
  77. package/src/core/utils/constants.ts +3 -9
  78. package/src/core/utils/emitter/emitter.ts +126 -0
  79. package/src/core/utils/emitter/emitter.types.ts +33 -16
  80. package/src/core/utils/emitter/index.ts +2 -74
  81. package/src/core/utils/fingerprint.ts +16 -5
  82. package/src/core/utils/guards.ts +91 -1
  83. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  84. package/src/core/utils/hash/index.ts +126 -0
  85. package/src/core/utils/hash/sha256.ts +179 -0
  86. package/src/core/utils/id.ts +33 -1
  87. package/src/core/utils/index.ts +15 -6
  88. package/src/core/utils/mime-sniff.ts +119 -0
  89. package/src/core/utils/sanitize-filename.ts +85 -0
  90. package/src/core/utils/url-safety.ts +156 -0
  91. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  92. package/src/react/index.ts +5 -2
  93. package/src/react/upload-provider.tsx +59 -95
  94. package/src/react/uploader.types.ts +80 -0
  95. package/src/react/use-dropzone.tsx +226 -0
  96. package/src/react/use-uploader.tsx +97 -93
  97. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  98. package/src/strategies/_shared/retry.ts +79 -0
  99. package/src/strategies/index.ts +9 -15
  100. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  101. package/src/strategies/multipart/index.ts +211 -116
  102. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  103. package/src/strategies/post/index.ts +113 -71
  104. package/src/strategies/put/__tests__/put.test.ts +132 -0
  105. package/src/strategies/put/index.ts +139 -0
  106. package/src/strategies/registry/index.ts +12 -9
  107. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  108. package/src/strategies/tus/index.ts +211 -0
  109. package/dist/async-DxRWu4le.js +0 -1
  110. package/dist/core-BNxznRif.js +0 -2
  111. package/dist/index-BXg9WIce.d.ts +0 -987
  112. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  113. package/src/core/client/config.types.ts +0 -77
  114. package/src/core/client/index.ts +0 -4
  115. package/src/core/client/plugins.types.ts +0 -46
  116. package/src/core/client/retry.types.ts +0 -6
  117. package/src/core/client/uploadClient.ts +0 -19
  118. package/src/core/contracts/backendApi.ts +0 -62
  119. package/src/core/contracts/cursor.types.ts +0 -23
  120. package/src/core/contracts/errors.types.ts +0 -67
  121. package/src/core/contracts/fingerprint.types.ts +0 -18
  122. package/src/core/contracts/intent.types.ts +0 -39
  123. package/src/core/contracts/result.types.ts +0 -11
  124. package/src/core/contracts/strategy.types.ts +0 -45
  125. package/src/core/engine/commands.types.ts +0 -17
  126. package/src/core/engine/event-map.types.ts +0 -45
  127. package/src/core/engine/internal-events.types.ts +0 -222
  128. package/src/core/engine/outcome.types.ts +0 -9
  129. package/src/core/engine/progress.types.ts +0 -15
@@ -0,0 +1,119 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest'
2
+ import { __resetPostWarningsForTests, type PostIntent, PostStrategy } from '../index'
3
+
4
+ function makeCtx(url: string) {
5
+ const file = new File(['hello'], 'a.txt', { type: 'text/plain' })
6
+ return {
7
+ intent: { strategy: 'post', fileId: 'f', url, fields: {} } satisfies PostIntent,
8
+ file,
9
+ signal: new AbortController().signal,
10
+ reportProgress: vi.fn(),
11
+ transport: {
12
+ postForm: vi.fn(async () => ({ headers: {} })),
13
+ },
14
+ }
15
+ }
16
+
17
+ describe('PostStrategy SSRF guard', () => {
18
+ beforeEach(() => {
19
+ __resetPostWarningsForTests()
20
+ })
21
+
22
+ test('rejects file://', async () => {
23
+ const s = PostStrategy()
24
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
25
+ await expect(s.start(makeCtx('file:///etc/passwd') as any)).rejects.toThrow(/forbidden protocol/)
26
+ })
27
+
28
+ test('rejects javascript:', async () => {
29
+ const s = PostStrategy()
30
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
31
+ await expect(s.start(makeCtx('javascript:alert(1)') as any)).rejects.toThrow(/forbidden protocol/)
32
+ })
33
+
34
+ test('rejects loopback', async () => {
35
+ const s = PostStrategy()
36
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
37
+ await expect(s.start(makeCtx('https://127.0.0.1/up') as any)).rejects.toThrow(/private/)
38
+ })
39
+
40
+ test('rejects AWS metadata IPv4', async () => {
41
+ const s = PostStrategy()
42
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
43
+ await expect(s.start(makeCtx('https://169.254.169.254/up') as any)).rejects.toThrow(/private/)
44
+ })
45
+
46
+ test('rejects host outside allowedHosts', async () => {
47
+ const s = PostStrategy({ allowedHosts: ['up.example.com'] })
48
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
49
+ await expect(s.start(makeCtx('https://evil.example.com/up') as any)).rejects.toThrow(/allow-list/)
50
+ })
51
+
52
+ test('accepts allowlisted host', async () => {
53
+ const s = PostStrategy({ allowedHosts: ['up.example.com'] })
54
+ const ctx = makeCtx('https://up.example.com/up')
55
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
56
+ await expect(s.start(ctx as any)).resolves.toBeUndefined()
57
+ expect(ctx.transport.postForm).toHaveBeenCalled()
58
+ })
59
+
60
+ test('allowPrivateHosts lets loopback through (opt-in)', async () => {
61
+ const s = PostStrategy({ allowPrivateHosts: true })
62
+ const ctx = makeCtx('https://127.0.0.1/up')
63
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
64
+ await expect(s.start(ctx as any)).resolves.toBeUndefined()
65
+ })
66
+
67
+ test('warns once when allowedHosts not set', async () => {
68
+ const s = PostStrategy()
69
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
70
+ try {
71
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
72
+ await s.start(makeCtx('https://up.example.com/up') as any).catch(() => {})
73
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
74
+ await s.start(makeCtx('https://up.example.com/up') as any).catch(() => {})
75
+ expect(warn).toHaveBeenCalledTimes(1)
76
+ } finally {
77
+ warn.mockRestore()
78
+ }
79
+ })
80
+
81
+ test('caller label is "post.intent"', async () => {
82
+ const s = PostStrategy()
83
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
84
+ await expect(s.start(makeCtx('file:///etc/passwd') as any)).rejects.toThrow(/post\.intent/)
85
+ })
86
+ })
87
+
88
+ describe('PostStrategy retry', () => {
89
+ beforeEach(() => {
90
+ __resetPostWarningsForTests()
91
+ })
92
+
93
+ test('retries a transient failure then succeeds', async () => {
94
+ const s = PostStrategy({ allowedHosts: ['up.example.com'], maxRetries: 2 })
95
+ const ctx = makeCtx('https://up.example.com/up')
96
+ let calls = 0
97
+ ctx.transport.postForm = vi.fn(async () => {
98
+ calls++
99
+ if (calls < 2) throw new Error('network error')
100
+ return { headers: {} }
101
+ })
102
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
103
+ await expect(s.start(ctx as any)).resolves.toBeUndefined()
104
+ expect(calls).toBe(2)
105
+ })
106
+
107
+ test('does not retry a non-transient failure', async () => {
108
+ const s = PostStrategy({ allowedHosts: ['up.example.com'] })
109
+ const ctx = makeCtx('https://up.example.com/up')
110
+ let calls = 0
111
+ ctx.transport.postForm = vi.fn(async () => {
112
+ calls++
113
+ throw new Error('Upload failed with status 403')
114
+ })
115
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
116
+ await expect(s.start(ctx as any)).rejects.toThrow(/403/)
117
+ expect(calls).toBe(1)
118
+ })
119
+ })
@@ -1,93 +1,135 @@
1
+ import type { Contracts } from '../../core'
2
+ import { UploadEngineError } from '../../core'
3
+ import { validateUploadUrl } from '../../core/utils/url-safety'
4
+ import { withRetry } from '../_shared/retry'
5
+
1
6
  /**
2
- * @fileoverview POST strategy implementation for simple file uploads.
3
- *
4
- * Implements the presigned POST upload strategy, suitable for:
5
- * - Small to medium files (< 100MB typically)
6
- * - Simple uploads without resume capability
7
- * - Direct form-based uploads to S3 or similar storage
8
- *
9
- * The strategy uses multipart/form-data POST requests with presigned
10
- * form fields provided by the backend. This is the simplest upload
11
- * method and doesn't support resumable uploads.
12
- *
13
- * @module upload-strategies/post
7
+ * Types for the presigned HTTP POST upload strategy.
8
+ * @author wildduck2 <https://github.com/wildduck2>
14
9
  */
10
+ export namespace PostStrategy {
11
+ /**
12
+ * Presigned HTTP POST strategy configuration options.
13
+ */
14
+ export type Config = {
15
+ /**
16
+ * Case-insensitive allow-list of host names (with optional port). When
17
+ * set, the presigned POST URL must match a listed host or it is rejected.
18
+ */
19
+ allowedHosts?: string[]
20
+ /**
21
+ * When `true`, allow private-network IP literals in the POST URL.
22
+ * Defaults to `false`.
23
+ */
24
+ allowPrivateHosts?: boolean
25
+ /**
26
+ * Retries after the first attempt for transient network failures.
27
+ * Defaults to the shared retry default (3).
28
+ */
29
+ maxRetries?: number
30
+ }
15
31
 
16
- import type { CursorMap, IntentMap, UploadResultBase, UploadStrategy } from '../../core/contracts'
17
-
18
- // ============================================================================
19
- // POST INTENT TYPE
20
- // ============================================================================
32
+ /**
33
+ * Presigned HTTP POST intent payload mapping parameters returned by backend endpoints.
34
+ */
35
+ export type Intent = {
36
+ /** Discriminant identifier matching strategy configuration registry keys. */
37
+ strategy: 'post'
38
+ /** Unique database identifier for the file resource. */
39
+ fileId: string
40
+ /** Presigned POST action destination URL (e.g. presigned S3/GCS bucket location). */
41
+ url: string
42
+ /** Key/value pair list representing form properties (e.g. AWS credential fields, policies). */
43
+ fields: Record<string, string>
44
+ /** Optional epoch string indicating when the pre-signed credentials lapse. */
45
+ expiresAt?: string
46
+ }
21
47
 
22
- export type PostIntent = {
23
- /** Strategy identifier - must be 'post' */
24
- strategy: 'post'
25
- /** Backend file identifier */
26
- fileId: string
27
- /** Presigned POST URL (form action) */
28
- url: string
29
- /** Form fields to include before the file */
30
- fields: Record<string, string>
31
- /** Optional expiration timestamp for the presigned URL */
32
- expiresAt?: string
48
+ /**
49
+ * Direct strategy cursor state representing that no resumable bytes exist.
50
+ */
51
+ export type Cursor = Record<string, never>
33
52
  }
34
53
 
35
- // ============================================================================
36
- // POST CURSOR TYPE
37
- // ============================================================================
54
+ /** Convenience alias for {@link PostStrategy.Intent}. */
55
+ export type PostIntent = PostStrategy.Intent
38
56
 
39
- export type PostCursor = {
40
- /** Bytes uploaded (for progress display only) */
41
- uploadedBytes: number
42
- }
57
+ let warnedMissingAllowedHosts = false
43
58
 
44
- // ============================================================================
45
- // POST STRATEGY
46
- // ============================================================================
59
+ /**
60
+ * Reset the warn-once latch. Test-only.
61
+ * @internal
62
+ */
63
+ export function __resetPostWarningsForTests(): void {
64
+ warnedMissingAllowedHosts = false
65
+ }
47
66
 
67
+ /**
68
+ * Presigned HTTP POST form strategy.
69
+ *
70
+ * Sends the entire file along with backend policy fields as a single
71
+ * `multipart/form-data` request body. Does not support resumability.
72
+ *
73
+ * Security: the presigned URL is backend-supplied and flows straight to the
74
+ * transport, so a compromised backend or MITM could return a `file:`,
75
+ * `javascript:`, or private-network URL. Every URL is checked with
76
+ * {@link validateUploadUrl}; set {@link PostStrategy.Config.allowedHosts} to
77
+ * lock it to known hosts, or leave `allowPrivateHosts` at its default `false`
78
+ * to block loopback/private addresses.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * const strategies = createStrategyRegistry([
83
+ * PostStrategy({ allowedHosts: ['uploads.example.com'] }),
84
+ * MultipartStrategy()
85
+ * ]);
86
+ * ```
87
+ * @author wildduck2 <https://github.com/wildduck2>
88
+ */
48
89
  export function PostStrategy<
49
- M extends IntentMap & { post: PostIntent },
50
- C extends CursorMap<M> & { post?: PostCursor },
51
- P extends string = string,
52
- R extends UploadResultBase = UploadResultBase,
53
- >(): UploadStrategy<M, C, P, R, 'post'> {
90
+ M extends Contracts.Intent.Map,
91
+ C extends Contracts.Cursor.Map<M>,
92
+ P extends string,
93
+ R extends Contracts.Result.Base = Contracts.Result.Base,
94
+ >(opts: PostStrategy.Config = {}): Contracts.Strategy.Me<M, C, P, R, 'post'> {
95
+ const allowedHosts = opts.allowedHosts ?? []
96
+ const allowPrivateHosts = opts.allowPrivateHosts === true
97
+
54
98
  return {
55
- /** Strategy identifier */
56
99
  id: 'post',
57
-
58
- /** POST uploads are not resumable */
59
100
  resumable: false,
60
101
 
61
- /**
62
- * Execute the POST upload.
63
- *
64
- * @param ctx - Strategy context
65
- * @throws Error if intent is missing URL
66
- */
67
102
  async start(ctx) {
68
- const intent = ctx.intent
103
+ const intent = ctx.intent as unknown as PostStrategy.Intent
69
104
 
70
- // Validate intent has required URL
71
105
  if (!intent.url) {
72
- throw new Error('Direct strategy: intent missing url/uploadUrl')
106
+ throw new UploadEngineError('validation_failed', { message: 'post.start: intent missing url' })
73
107
  }
74
108
 
75
- // Upload using form POST
76
- await ctx.transport.postForm({
77
- url: intent.url,
78
- file: ctx.file,
79
- fields: intent.fields,
80
- filename: ctx.file.name,
81
- signal: ctx.signal,
82
- /**
83
- * Report progress during upload.
84
- * @param uploadedBytes - Bytes uploaded so far
85
- * @param totalBytes - Total file size
86
- */
87
- onProgress(uploadedBytes, totalBytes) {
88
- ctx.reportProgress({ uploadedBytes, totalBytes })
89
- },
90
- })
109
+ if (allowedHosts.length === 0 && !warnedMissingAllowedHosts) {
110
+ warnedMissingAllowedHosts = true
111
+ console.warn(
112
+ '[duck-upload] PostStrategy: no `allowedHosts` configured. Presigned POST URLs will be host-unrestricted. ' +
113
+ 'Set PostStrategy.Config.allowedHosts to lock the upload host.',
114
+ )
115
+ }
116
+
117
+ validateUploadUrl(intent.url, 'post.intent', { allowedHosts, allowPrivateHosts })
118
+
119
+ await withRetry(
120
+ () =>
121
+ ctx.transport.postForm({
122
+ url: intent.url,
123
+ file: ctx.file,
124
+ fields: intent.fields,
125
+ filename: ctx.file.name,
126
+ signal: ctx.signal,
127
+ onProgress(uploadedBytes, totalBytes) {
128
+ ctx.reportProgress({ uploadedBytes, totalBytes })
129
+ },
130
+ }),
131
+ { signal: ctx.signal, ...(opts.maxRetries !== undefined ? { maxRetries: opts.maxRetries } : {}) },
132
+ )
91
133
  },
92
134
  }
93
135
  }
@@ -0,0 +1,132 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest'
2
+ import { __resetPutWarningsForTests, type PutIntent, PutStrategy } from '../index'
3
+
4
+ function makeCtx(url: string, overrides: Partial<PutIntent> = {}) {
5
+ const file = new File(['hello world'], 'a.txt', { type: 'text/plain' })
6
+ return {
7
+ intent: { strategy: 'put', fileId: 'f', url, ...overrides } satisfies PutIntent,
8
+ file,
9
+ signal: new AbortController().signal,
10
+ reportProgress: vi.fn(),
11
+ transport: {
12
+ put: vi.fn(async () => ({ etag: 'abc', headers: {} })),
13
+ },
14
+ }
15
+ }
16
+
17
+ describe('PutStrategy SSRF guard', () => {
18
+ beforeEach(() => {
19
+ __resetPutWarningsForTests()
20
+ })
21
+
22
+ test('rejects file://', async () => {
23
+ const s = PutStrategy()
24
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
25
+ await expect(s.start(makeCtx('file:///etc/passwd') as any)).rejects.toThrow(/forbidden protocol/)
26
+ })
27
+
28
+ test('rejects javascript:', async () => {
29
+ const s = PutStrategy()
30
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
31
+ await expect(s.start(makeCtx('javascript:alert(1)') as any)).rejects.toThrow(/forbidden protocol/)
32
+ })
33
+
34
+ test('rejects loopback', async () => {
35
+ const s = PutStrategy()
36
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
37
+ await expect(s.start(makeCtx('https://127.0.0.1/up') as any)).rejects.toThrow(/private/)
38
+ })
39
+
40
+ test('rejects AWS metadata IPv4', async () => {
41
+ const s = PutStrategy()
42
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
43
+ await expect(s.start(makeCtx('https://169.254.169.254/up') as any)).rejects.toThrow(/private/)
44
+ })
45
+
46
+ test('rejects host outside allowedHosts', async () => {
47
+ const s = PutStrategy({ allowedHosts: ['up.example.com'] })
48
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
49
+ await expect(s.start(makeCtx('https://evil.example.com/up') as any)).rejects.toThrow(/allow-list/)
50
+ })
51
+
52
+ test('accepts allowlisted host and PUTs the file body', async () => {
53
+ const s = PutStrategy({ allowedHosts: ['up.example.com'] })
54
+ const ctx = makeCtx('https://up.example.com/up', { headers: { 'Content-Type': 'text/plain' } })
55
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
56
+ await expect(s.start(ctx as any)).resolves.toBeUndefined()
57
+ expect(ctx.transport.put).toHaveBeenCalledTimes(1)
58
+ const arg = (ctx.transport.put.mock.calls[0] as unknown as [{ url: string; body: unknown; headers: unknown }])[0]
59
+ expect(arg.url).toBe('https://up.example.com/up')
60
+ expect(arg.body).toBe(ctx.file)
61
+ expect(arg.headers).toEqual({ 'Content-Type': 'text/plain' })
62
+ })
63
+
64
+ test('allowPrivateHosts lets loopback through (opt-in)', async () => {
65
+ const s = PutStrategy({ allowPrivateHosts: true })
66
+ const ctx = makeCtx('https://127.0.0.1/up')
67
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
68
+ await expect(s.start(ctx as any)).resolves.toBeUndefined()
69
+ })
70
+
71
+ test('warns once when allowedHosts not set', async () => {
72
+ const s = PutStrategy()
73
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
74
+ try {
75
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
76
+ await s.start(makeCtx('https://up.example.com/up') as any).catch(() => {})
77
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
78
+ await s.start(makeCtx('https://up.example.com/up') as any).catch(() => {})
79
+ expect(warn).toHaveBeenCalledTimes(1)
80
+ } finally {
81
+ warn.mockRestore()
82
+ }
83
+ })
84
+
85
+ test('caller label is "put.intent"', async () => {
86
+ const s = PutStrategy()
87
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
88
+ await expect(s.start(makeCtx('file:///etc/passwd') as any)).rejects.toThrow(/put\.intent/)
89
+ })
90
+
91
+ test('throws when intent has no url', async () => {
92
+ const s = PutStrategy()
93
+ const ctx = makeCtx('https://up.example.com/up')
94
+ // biome-ignore lint/suspicious/noExplicitAny: intentionally clearing the url
95
+ ;(ctx.intent as any).url = ''
96
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
97
+ await expect(s.start(ctx as any)).rejects.toThrow(/missing url/)
98
+ })
99
+ })
100
+
101
+ describe('PutStrategy retry', () => {
102
+ beforeEach(() => {
103
+ __resetPutWarningsForTests()
104
+ })
105
+
106
+ test('retries a transient failure then succeeds', async () => {
107
+ const s = PutStrategy({ allowedHosts: ['up.example.com'], maxRetries: 2 })
108
+ const ctx = makeCtx('https://up.example.com/up')
109
+ let calls = 0
110
+ ctx.transport.put = vi.fn(async () => {
111
+ calls++
112
+ if (calls < 2) throw new Error('network error')
113
+ return { etag: 'ok', headers: {} }
114
+ })
115
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
116
+ await expect(s.start(ctx as any)).resolves.toBeUndefined()
117
+ expect(calls).toBe(2)
118
+ })
119
+
120
+ test('does not retry a non-transient failure', async () => {
121
+ const s = PutStrategy({ allowedHosts: ['up.example.com'] })
122
+ const ctx = makeCtx('https://up.example.com/up')
123
+ let calls = 0
124
+ ctx.transport.put = vi.fn(async () => {
125
+ calls++
126
+ throw new Error('Upload failed with status 403')
127
+ })
128
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
129
+ await expect(s.start(ctx as any)).rejects.toThrow(/403/)
130
+ expect(calls).toBe(1)
131
+ })
132
+ })
@@ -0,0 +1,139 @@
1
+ import type { Contracts } from '../../core'
2
+ import { UploadEngineError } from '../../core'
3
+ import { validateUploadUrl } from '../../core/utils/url-safety'
4
+ import { withRetry } from '../_shared/retry'
5
+
6
+ /**
7
+ * Types for the single-request presigned HTTP PUT upload strategy.
8
+ * @author wildduck2 <https://github.com/wildduck2>
9
+ */
10
+ export namespace PutStrategy {
11
+ /**
12
+ * Presigned HTTP PUT strategy configuration options.
13
+ */
14
+ export type Config = {
15
+ /**
16
+ * Case-insensitive allow-list of host names (with optional port). When
17
+ * set, the presigned PUT URL must match a listed host or it is rejected.
18
+ */
19
+ allowedHosts?: string[]
20
+ /**
21
+ * When `true`, allow private-network IP literals in the PUT URL.
22
+ * Defaults to `false`.
23
+ */
24
+ allowPrivateHosts?: boolean
25
+ /**
26
+ * Retries after the first attempt for transient network failures.
27
+ * Defaults to the shared retry default (3).
28
+ */
29
+ maxRetries?: number
30
+ }
31
+
32
+ /**
33
+ * Presigned HTTP PUT intent payload. The whole file body is sent in one
34
+ * request to a backend-signed URL (e.g. S3 `putObject`, GCS signed PUT).
35
+ */
36
+ export type Intent = {
37
+ /** Discriminant identifier matching strategy configuration registry keys. */
38
+ strategy: 'put'
39
+ /** Unique database identifier for the file resource. */
40
+ fileId: string
41
+ /** Presigned PUT destination URL. */
42
+ url: string
43
+ /** Optional request headers required by the signature (e.g. `Content-Type`). */
44
+ headers?: Record<string, string>
45
+ /** Optional epoch string indicating when the pre-signed credentials lapse. */
46
+ expiresAt?: string
47
+ }
48
+
49
+ /**
50
+ * Direct strategy cursor state representing that no resumable bytes exist.
51
+ */
52
+ export type Cursor = Record<string, never>
53
+ }
54
+
55
+ /** Convenience alias for {@link PutStrategy.Intent}. */
56
+ export type PutIntent = PutStrategy.Intent
57
+
58
+ let warnedMissingAllowedHosts = false
59
+
60
+ /**
61
+ * Reset the warn-once latch. Test-only.
62
+ * @internal
63
+ */
64
+ export function __resetPutWarningsForTests(): void {
65
+ warnedMissingAllowedHosts = false
66
+ }
67
+
68
+ /**
69
+ * Presigned single-request HTTP PUT strategy.
70
+ *
71
+ * Sends the entire file as the request body to a backend-signed URL in one PUT.
72
+ * The most common direct-to-storage pattern (S3 `putObject`, GCS/Azure signed
73
+ * PUT). Not resumable — use {@link multipartStrategy} or the tus strategy for
74
+ * resumable transfers.
75
+ *
76
+ * Security: the presigned URL is backend-supplied and flows straight to the
77
+ * transport, so a compromised backend or MITM could return a `file:`,
78
+ * `javascript:`, or private-network URL. Every URL is checked with
79
+ * {@link validateUploadUrl}; set {@link PutStrategy.Config.allowedHosts} to
80
+ * lock it to known hosts, or leave `allowPrivateHosts` at its default `false`
81
+ * to block loopback/private addresses.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * const strategies = createStrategyRegistry([
86
+ * PutStrategy({ allowedHosts: ['uploads.example.com'] }),
87
+ * multipartStrategy(),
88
+ * ]);
89
+ * ```
90
+ * @author wildduck2 <https://github.com/wildduck2>
91
+ */
92
+ export function PutStrategy<
93
+ M extends Contracts.Intent.Map,
94
+ C extends Contracts.Cursor.Map<M>,
95
+ P extends string,
96
+ R extends Contracts.Result.Base = Contracts.Result.Base,
97
+ >(opts: PutStrategy.Config = {}): Contracts.Strategy.Me<M, C, P, R, 'put'> {
98
+ const allowedHosts = opts.allowedHosts ?? []
99
+ const allowPrivateHosts = opts.allowPrivateHosts === true
100
+
101
+ return {
102
+ id: 'put',
103
+ resumable: false,
104
+
105
+ async start(ctx) {
106
+ const intent = ctx.intent as unknown as PutStrategy.Intent
107
+
108
+ if (!intent.url) {
109
+ throw new UploadEngineError('validation_failed', { message: 'put.start: intent missing url' })
110
+ }
111
+
112
+ if (allowedHosts.length === 0 && !warnedMissingAllowedHosts) {
113
+ warnedMissingAllowedHosts = true
114
+ console.warn(
115
+ '[duck-upload] PutStrategy: no `allowedHosts` configured. Presigned PUT URLs will be host-unrestricted. ' +
116
+ 'Set PutStrategy.Config.allowedHosts to lock the upload host.',
117
+ )
118
+ }
119
+
120
+ validateUploadUrl(intent.url, 'put.intent', { allowedHosts, allowPrivateHosts })
121
+
122
+ const totalBytes = ctx.file.size
123
+
124
+ await withRetry(
125
+ () =>
126
+ ctx.transport.put({
127
+ url: intent.url,
128
+ body: ctx.file,
129
+ headers: intent.headers ?? {},
130
+ signal: ctx.signal,
131
+ onProgress(uploadedBytes, total) {
132
+ ctx.reportProgress({ uploadedBytes, totalBytes: total || totalBytes })
133
+ },
134
+ }),
135
+ { signal: ctx.signal, ...(opts.maxRetries !== undefined ? { maxRetries: opts.maxRetries } : {}) },
136
+ )
137
+ },
138
+ }
139
+ }
@@ -4,17 +4,20 @@
4
4
  * Core strategy types live in `core/contracts/strategy.types.ts`.
5
5
  */
6
6
 
7
- import type { CursorMap, IntentMap, StrategyRegistry, UploadResultBase, UploadStrategy } from '../../core/contracts'
8
-
9
- export type { StrategyCtx, StrategyRegistry, UploadStrategy } from '../../core/contracts'
7
+ import type { Contracts } from '../../core/contracts'
10
8
 
9
+ /**
10
+ * Create an empty strategy registry. Register upload strategies with `set`, then
11
+ * resolve them by id with `get` / `has`.
12
+ * @author wildduck2 <https://github.com/wildduck2>
13
+ */
11
14
  export function createStrategyRegistry<
12
- M extends IntentMap,
13
- C extends CursorMap<M>,
14
- P extends string = string,
15
- R extends UploadResultBase = UploadResultBase,
16
- >(): StrategyRegistry<M, C, P, R> {
17
- const map: Partial<{ [K in keyof M & string]: UploadStrategy<M, C, P, R, K> }> = {}
15
+ M extends Contracts.Intent.Map,
16
+ C extends Contracts.Cursor.Map<M>,
17
+ P extends string,
18
+ R extends Contracts.Result.Base = Contracts.Result.Base,
19
+ >(): Contracts.Strategy.Registry<M, C, P, R> {
20
+ const map: Partial<{ [K in keyof M & string]: Contracts.Strategy.Me<M, C, P, R, K> }> = {}
18
21
 
19
22
  return {
20
23
  get(id) {