@modern-admin/feature-upload 0.1.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 (66) hide show
  1. package/dist/graphql.d.ts +36 -0
  2. package/dist/graphql.d.ts.map +1 -0
  3. package/dist/graphql.js +141 -0
  4. package/dist/graphql.js.map +1 -0
  5. package/dist/index.d.ts +7 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +12 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/nest/index.d.ts +3 -0
  10. package/dist/nest/index.d.ts.map +1 -0
  11. package/dist/nest/index.js +4 -0
  12. package/dist/nest/index.js.map +1 -0
  13. package/dist/nest/upload-sweeper.service.d.ts +32 -0
  14. package/dist/nest/upload-sweeper.service.d.ts.map +1 -0
  15. package/dist/nest/upload-sweeper.service.js +68 -0
  16. package/dist/nest/upload-sweeper.service.js.map +1 -0
  17. package/dist/nest/upload.controller.d.ts +51 -0
  18. package/dist/nest/upload.controller.d.ts.map +1 -0
  19. package/dist/nest/upload.controller.js +220 -0
  20. package/dist/nest/upload.controller.js.map +1 -0
  21. package/dist/nest/upload.module.d.ts +28 -0
  22. package/dist/nest/upload.module.d.ts.map +1 -0
  23. package/dist/nest/upload.module.js +51 -0
  24. package/dist/nest/upload.module.js.map +1 -0
  25. package/dist/nest/upload.tokens.d.ts +23 -0
  26. package/dist/nest/upload.tokens.d.ts.map +1 -0
  27. package/dist/nest/upload.tokens.js +6 -0
  28. package/dist/nest/upload.tokens.js.map +1 -0
  29. package/dist/pending-registry.d.ts +60 -0
  30. package/dist/pending-registry.d.ts.map +1 -0
  31. package/dist/pending-registry.js +106 -0
  32. package/dist/pending-registry.js.map +1 -0
  33. package/dist/providers/local.d.ts +36 -0
  34. package/dist/providers/local.d.ts.map +1 -0
  35. package/dist/providers/local.js +44 -0
  36. package/dist/providers/local.js.map +1 -0
  37. package/dist/providers/s3.d.ts +96 -0
  38. package/dist/providers/s3.d.ts.map +1 -0
  39. package/dist/providers/s3.js +164 -0
  40. package/dist/providers/s3.js.map +1 -0
  41. package/dist/registry.d.ts +27 -0
  42. package/dist/registry.d.ts.map +1 -0
  43. package/dist/registry.js +25 -0
  44. package/dist/registry.js.map +1 -0
  45. package/dist/types.d.ts +130 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +9 -0
  48. package/dist/types.js.map +1 -0
  49. package/dist/upload-feature.d.ts +31 -0
  50. package/dist/upload-feature.d.ts.map +1 -0
  51. package/dist/upload-feature.js +170 -0
  52. package/dist/upload-feature.js.map +1 -0
  53. package/package.json +94 -0
  54. package/src/graphql.ts +185 -0
  55. package/src/index.ts +22 -0
  56. package/src/nest/index.ts +4 -0
  57. package/src/nest/upload-sweeper.service.ts +52 -0
  58. package/src/nest/upload.controller.ts +228 -0
  59. package/src/nest/upload.module.ts +44 -0
  60. package/src/nest/upload.tokens.ts +24 -0
  61. package/src/pending-registry.ts +115 -0
  62. package/src/providers/local.ts +63 -0
  63. package/src/providers/s3.ts +226 -0
  64. package/src/registry.ts +39 -0
  65. package/src/types.ts +137 -0
  66. package/src/upload-feature.ts +208 -0
package/src/graphql.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * GraphQL extension for the upload feature — mirrors the REST upload
3
+ * controller (`POST /admin/api/resources/:id/actions/upload` and `DELETE`)
4
+ * with two mutations:
5
+ *
6
+ * - `adminUpload(resourceId, field, file)` — upload a single file via the
7
+ * `Upload` scalar (multipart/form-data per the GraphQL multipart spec).
8
+ * Returns `UploadedFileInfo`.
9
+ * - `adminCancelUpload(resourceId, field, key)` — cancel a still-pending
10
+ * upload (file uploaded but record not yet saved). Returns `Boolean`.
11
+ *
12
+ * The extension is wired in the host application:
13
+ * ```ts
14
+ * ModernAdminGraphqlModule.forRoot({
15
+ * extensions: [uploadGraphqlExtension()],
16
+ * })
17
+ * ```
18
+ *
19
+ * Authorisation re-uses the same `ModernAdmin.invoke()` access checks via
20
+ * `findResource(resourceId, currentAdmin)`.
21
+ *
22
+ * `@modern-admin/graphql` is an *optional peer dependency* of feature-upload —
23
+ * if you do not use the GraphQL transport you do not need to import this
24
+ * module at all.
25
+ */
26
+
27
+ import {
28
+ GraphQLBoolean,
29
+ GraphQLNonNull,
30
+ GraphQLObjectType,
31
+ GraphQLString,
32
+ GraphQLInt,
33
+ } from 'graphql'
34
+ import {
35
+ type ExtensionContext,
36
+ type GraphqlSchemaExtension,
37
+ type GraphqlContext,
38
+ } from '@modern-admin/graphql'
39
+ import { ForbiddenError, ResourceNotFoundError } from '@modern-admin/core'
40
+ import { UploadProviderRegistry } from './registry.js'
41
+ import { PendingUploadsRegistry } from './pending-registry.js'
42
+ import type { UploadedFileInfo } from './types.js'
43
+
44
+ /** Default TTL for pending upload entries, mirrored from the controller. */
45
+ const DEFAULT_PENDING_TTL_MS = 60 * 60 * 1000
46
+
47
+ export interface UploadGraphqlExtensionOptions {
48
+ /**
49
+ * TTL applied when registering an uploaded key as pending. Mirrors the
50
+ * `pendingTtlMs` option on `ModernAdminUploadModule.forRoot()` — set both
51
+ * to the same value to keep REST and GraphQL behaviour identical.
52
+ */
53
+ pendingTtlMs?: number
54
+ }
55
+
56
+ export function uploadGraphqlExtension(
57
+ options: UploadGraphqlExtensionOptions = {},
58
+ ): (ctx: ExtensionContext) => GraphqlSchemaExtension {
59
+ const ttlMs = options.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS
60
+ return ({ Upload }): GraphqlSchemaExtension => {
61
+ const UploadedFileInfoType = new GraphQLObjectType<UploadedFileInfo, GraphqlContext>({
62
+ name: 'UploadedFileInfo',
63
+ description: 'Metadata returned for one successfully uploaded file.',
64
+ fields: () => ({
65
+ key: { type: new GraphQLNonNull(GraphQLString) },
66
+ url: { type: new GraphQLNonNull(GraphQLString) },
67
+ name: { type: new GraphQLNonNull(GraphQLString) },
68
+ size: { type: new GraphQLNonNull(GraphQLInt) },
69
+ mimeType: { type: new GraphQLNonNull(GraphQLString) },
70
+ }),
71
+ })
72
+
73
+ return {
74
+ name: 'feature-upload',
75
+ types: [UploadedFileInfoType],
76
+ mutations: {
77
+ adminUpload: {
78
+ type: new GraphQLNonNull(UploadedFileInfoType),
79
+ description:
80
+ 'Upload a single file for an upload-enabled property. Multi-file ' +
81
+ 'properties accept one mutation invocation per file.',
82
+ args: {
83
+ resourceId: { type: new GraphQLNonNull(GraphQLString) },
84
+ field: { type: new GraphQLNonNull(GraphQLString) },
85
+ file: { type: new GraphQLNonNull(Upload) },
86
+ },
87
+ async resolve(_src, args, ctx) {
88
+ const { resourceId, field } = args as { resourceId: string; field: string }
89
+ const { providerId, registered } = resolveProperty(ctx, resourceId, field)
90
+ const upload = args.file as {
91
+ filename: string
92
+ mimeType: string
93
+ size: number
94
+ buffer: Buffer
95
+ }
96
+ const computedKey = registered.uploadPath
97
+ ? registered.uploadPath(upload.filename)
98
+ : undefined
99
+ const key = await registered.provider.upload(
100
+ {
101
+ originalName: upload.filename,
102
+ mimeType: upload.mimeType,
103
+ size: upload.size,
104
+ buffer: upload.buffer,
105
+ },
106
+ computedKey,
107
+ )
108
+ PendingUploadsRegistry.track(key, providerId, ttlMs)
109
+ const url = await registered.provider.getUrl(key)
110
+ const info: UploadedFileInfo = {
111
+ key,
112
+ url,
113
+ name: upload.filename,
114
+ size: upload.size,
115
+ mimeType: upload.mimeType,
116
+ }
117
+ return info
118
+ },
119
+ },
120
+ adminCancelUpload: {
121
+ type: new GraphQLNonNull(GraphQLBoolean),
122
+ description:
123
+ 'Cancel a still-pending upload (file uploaded but record not yet ' +
124
+ 'saved). Returns false when the key is no longer pending.',
125
+ args: {
126
+ resourceId: { type: new GraphQLNonNull(GraphQLString) },
127
+ field: { type: new GraphQLNonNull(GraphQLString) },
128
+ key: { type: new GraphQLNonNull(GraphQLString) },
129
+ },
130
+ async resolve(_src, args, ctx) {
131
+ const { resourceId, field, key } = args as {
132
+ resourceId: string
133
+ field: string
134
+ key: string
135
+ }
136
+ // Validate the resource/field exists & is upload-enabled. Mirrors
137
+ // the REST controller's check — prevents leaking arbitrary
138
+ // resource ids to authenticated clients.
139
+ resolveProperty(ctx, resourceId, field)
140
+ return PendingUploadsRegistry.cancel(key)
141
+ },
142
+ },
143
+ },
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Resolve the resource + property and ensure it is wired to an upload
150
+ * provider. Mirrors the private helper in the REST upload controller — kept
151
+ * private to this module to avoid coupling. Throws GraphQL-friendly Error
152
+ * objects (not Nest exceptions) so they surface as standard GraphQL errors.
153
+ */
154
+ function resolveProperty(
155
+ ctx: GraphqlContext,
156
+ resourceId: string,
157
+ field: string,
158
+ ): { providerId: string; registered: NonNullable<ReturnType<typeof UploadProviderRegistry.get>> } {
159
+ let resource
160
+ try {
161
+ resource = ctx.admin.findResource(resourceId)
162
+ } catch (err) {
163
+ if (err instanceof ResourceNotFoundError) throw new Error(err.message)
164
+ if (err instanceof ForbiddenError) throw new Error(err.message)
165
+ throw err
166
+ }
167
+ const decorator = resource.decorate()
168
+ const prop = decorator.getPropertyByKey(field)
169
+ if (!prop) {
170
+ throw new Error(`Property "${field}" not found on resource "${resourceId}"`)
171
+ }
172
+ const propJson = prop.toJSON()
173
+ const providerId = propJson.custom?.uploadProviderId as string | undefined
174
+ if (!providerId) {
175
+ throw new Error(
176
+ `Property "${field}" on resource "${resourceId}" is not configured for upload. ` +
177
+ 'Apply uploadFeature() to the resource.',
178
+ )
179
+ }
180
+ const registered = UploadProviderRegistry.get(providerId)
181
+ if (!registered) {
182
+ throw new Error(`Upload provider "${providerId}" is not registered.`)
183
+ }
184
+ return { providerId, registered }
185
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ // @modern-admin/feature-upload — file upload plugin for modern-admin resources.
2
+ //
3
+ // Usage:
4
+ // import { uploadFeature, LocalUploadProvider, S3UploadProvider } from '@modern-admin/feature-upload'
5
+ // import { ModernAdminUploadModule } from '@modern-admin/feature-upload/nest'
6
+
7
+ export { uploadFeature } from './upload-feature.js'
8
+ export { UploadProviderRegistry } from './registry.js'
9
+ export { PendingUploadsRegistry } from './pending-registry.js'
10
+
11
+ // Built-in providers
12
+ export { LocalUploadProvider, type LocalUploadOptions } from './providers/local.js'
13
+ export { S3UploadProvider, type S3UploadOptions } from './providers/s3.js'
14
+
15
+ // Types
16
+ export type {
17
+ IUploadProvider,
18
+ UploadedFile,
19
+ UploadedFileInfo,
20
+ UploadFeatureOptions,
21
+ UploadPropertyConfig,
22
+ } from './types.js'
@@ -0,0 +1,4 @@
1
+ // @modern-admin/feature-upload/nest — NestJS module + controller for file uploads.
2
+
3
+ export { ModernAdminUploadModule, type ModernAdminUploadModuleOptions } from './upload.module.js'
4
+ export { UploadController } from './upload.controller.js'
@@ -0,0 +1,52 @@
1
+ /**
2
+ * UploadSweeperService — periodic background task that purges expired entries
3
+ * from `PendingUploadsRegistry`.
4
+ *
5
+ * The interval starts when Nest calls `onModuleInit` and stops on
6
+ * `onModuleDestroy`. Set `sweepIntervalMs: 0` in the module options to
7
+ * disable scheduling (useful for tests that drive `sweep()` manually).
8
+ *
9
+ * TODO(roadmap): replace the in-process `setInterval` driver with a BullMQ
10
+ * (Redis-backed) job queue. Reasons:
11
+ * - the registry will move to Redis in the multi-instance deployment story,
12
+ * and the sweeper job belongs in the same place;
13
+ * - BullMQ gives us cron scheduling, retries on transient storage errors,
14
+ * visibility/metrics through Bull Board, and crash safety (a process
15
+ * restart resumes scheduled sweeps instead of forgetting them);
16
+ * - per-key cancel can become a delayed job (`removeJob` on confirm) which
17
+ * is more efficient than a periodic full-table scan once we have many
18
+ * pending entries.
19
+ * The migration should keep the same `PendingUploadsRegistry` API surface so
20
+ * call sites (controller, hooks) do not need to change — only the storage
21
+ * (Map → Redis) and the sweeper driver (setInterval → BullMQ Worker) flip.
22
+ */
23
+
24
+ import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'
25
+ import { PendingUploadsRegistry } from '../pending-registry.js'
26
+ import { UPLOAD_MODULE_OPTIONS, type ModernAdminUploadModuleOptions } from './upload.tokens.js'
27
+
28
+ const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000
29
+
30
+ @Injectable()
31
+ export class UploadSweeperService implements OnModuleInit, OnModuleDestroy {
32
+ private timer: ReturnType<typeof setInterval> | null = null
33
+
34
+ constructor(@Inject(UPLOAD_MODULE_OPTIONS) private readonly options: ModernAdminUploadModuleOptions) {}
35
+
36
+ onModuleInit(): void {
37
+ const interval = this.options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS
38
+ if (interval <= 0) return
39
+ this.timer = setInterval(() => {
40
+ void PendingUploadsRegistry.sweep()
41
+ }, interval)
42
+ // Allow the Node.js process to exit even if the timer is still scheduled.
43
+ if (typeof this.timer.unref === 'function') this.timer.unref()
44
+ }
45
+
46
+ onModuleDestroy(): void {
47
+ if (this.timer) {
48
+ clearInterval(this.timer)
49
+ this.timer = null
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * UploadController — handles file uploads for `type: 'file'` properties.
3
+ *
4
+ * Endpoints
5
+ * ---------
6
+ * POST /admin/api/resources/:resourceId/actions/upload?field=<propertyPath>
7
+ * Content-Type: multipart/form-data
8
+ * Accepts one or more files (any field names). Returns `UploadedFileInfo[]` —
9
+ * callers writing to a single-value `'file'` property take the first item;
10
+ * `isArray: true` properties consume the whole array.
11
+ *
12
+ * DELETE /admin/api/resources/:resourceId/actions/upload?field=<path>&key=<key>
13
+ * Cancels a still-pending upload (uploaded but not yet saved). Calls
14
+ * `PendingUploadsRegistry.cancel(key)` which deletes the file from
15
+ * storage. No-op (404) if the key is no longer pending — protects already
16
+ * persisted files from being deleted via this endpoint.
17
+ *
18
+ * Authorisation re-uses `ModernAdminAuthGuard` from `@modern-admin/nest` —
19
+ * requires `ModernAdminModule.forRoot()` to be registered (globally or as a
20
+ * parent module).
21
+ */
22
+
23
+ import {
24
+ BadRequestException,
25
+ Controller,
26
+ Delete,
27
+ ForbiddenException,
28
+ HttpCode,
29
+ Inject,
30
+ InternalServerErrorException,
31
+ NotFoundException,
32
+ Post,
33
+ Param,
34
+ Query,
35
+ Req,
36
+ UseGuards,
37
+ } from '@nestjs/common'
38
+ import { ApiCookieAuth, ApiTags } from '@nestjs/swagger'
39
+ import type { IncomingMessage } from 'node:http'
40
+ import Busboy from 'busboy'
41
+ import {
42
+ ForbiddenError,
43
+ ResourceNotFoundError,
44
+ type ModernAdmin,
45
+ } from '@modern-admin/core'
46
+ import { MODERN_ADMIN, ModernAdminAuthGuard } from '@modern-admin/nest'
47
+ import { UploadProviderRegistry } from '../registry.js'
48
+ import { PendingUploadsRegistry } from '../pending-registry.js'
49
+ import { UPLOAD_MODULE_OPTIONS, type ModernAdminUploadModuleOptions } from './upload.tokens.js'
50
+ import type { UploadedFile, UploadedFileInfo } from '../types.js'
51
+
52
+ /** Reads every file from a multipart/form-data stream using busboy. */
53
+ function parseAllFiles(req: IncomingMessage): Promise<UploadedFile[]> {
54
+ return new Promise((resolve, reject) => {
55
+ let bb: ReturnType<typeof Busboy>
56
+ try {
57
+ bb = Busboy({ headers: req.headers as Record<string, string> })
58
+ } catch {
59
+ reject(new BadRequestException('Request is not multipart/form-data'))
60
+ return
61
+ }
62
+
63
+ const files: UploadedFile[] = []
64
+ /** Number of streams that have started but not yet ended. */
65
+ let pending = 0
66
+ let finished = false
67
+ let settled = false
68
+ let firstError: unknown
69
+
70
+ const tryResolve = (): void => {
71
+ if (settled || !finished || pending > 0) return
72
+ settled = true
73
+ if (firstError) reject(firstError)
74
+ else if (files.length === 0) reject(new BadRequestException('No file found in request body'))
75
+ else resolve(files)
76
+ }
77
+
78
+ bb.on('file', (_fieldname, stream, info) => {
79
+ pending++
80
+ const chunks: Buffer[] = []
81
+ stream.on('data', (chunk: Buffer) => chunks.push(chunk))
82
+ stream.on('end', () => {
83
+ const buffer = Buffer.concat(chunks)
84
+ files.push({
85
+ originalName: info.filename || 'upload',
86
+ mimeType: info.mimeType || 'application/octet-stream',
87
+ size: buffer.length,
88
+ buffer,
89
+ })
90
+ pending--
91
+ tryResolve()
92
+ })
93
+ stream.on('error', (err) => {
94
+ firstError = firstError ?? err
95
+ pending--
96
+ tryResolve()
97
+ })
98
+ })
99
+
100
+ bb.on('finish', () => {
101
+ finished = true
102
+ tryResolve()
103
+ })
104
+
105
+ bb.on('error', (err: unknown) => {
106
+ firstError = firstError ?? err
107
+ finished = true
108
+ tryResolve()
109
+ })
110
+
111
+ req.pipe(bb)
112
+ })
113
+ }
114
+
115
+ @ApiTags('Admin / Uploads')
116
+ @ApiCookieAuth('session')
117
+ @Controller('admin/api/resources/:resourceId/actions')
118
+ @UseGuards(ModernAdminAuthGuard)
119
+ export class UploadController {
120
+ constructor(
121
+ @Inject(MODERN_ADMIN) private readonly admin: ModernAdmin,
122
+ @Inject(UPLOAD_MODULE_OPTIONS) private readonly moduleOptions: ModernAdminUploadModuleOptions,
123
+ ) {}
124
+
125
+ /**
126
+ * Upload one or more files for a specific resource property.
127
+ *
128
+ * @param resourceId Admin resource id (e.g. `'users'`)
129
+ * @param field Property path (e.g. `'avatar'` / `'gallery'`)
130
+ * @param req Raw Express / Node.js `IncomingMessage`
131
+ */
132
+ @Post('upload')
133
+ async upload(
134
+ @Param('resourceId') resourceId: string,
135
+ @Query('field') field: string,
136
+ @Req() req: IncomingMessage,
137
+ ): Promise<UploadedFileInfo[]> {
138
+ const { providerId, registered } = this.resolveProperty(resourceId, field)
139
+
140
+ // 1 — Parse the multipart body (collect every file).
141
+ const files = await parseAllFiles(req)
142
+
143
+ // 2 — If the property is single-value, only the first file is honoured.
144
+ // The frontend sends one anyway; this is a safety bound.
145
+ const accepted = registered.isArray ? files : files.slice(0, 1)
146
+
147
+ // 3 — Upload each file, computing the storage key per-file so a custom
148
+ // `uploadPath` generator can produce unique keys for each upload.
149
+ const ttlMs = this.moduleOptions.pendingTtlMs ?? 60 * 60 * 1000
150
+ const results: UploadedFileInfo[] = []
151
+ for (const file of accepted) {
152
+ const computedKey = registered.uploadPath ? registered.uploadPath(file.originalName) : undefined
153
+ const key = await registered.provider.upload(file, computedKey)
154
+ // Track as pending — it will be confirmed by `new.after`/`edit.after`
155
+ // when the form is saved, or cleaned up by the sweeper when the TTL
156
+ // expires (whichever comes first).
157
+ PendingUploadsRegistry.track(key, providerId, ttlMs)
158
+ const url = await registered.provider.getUrl(key)
159
+ results.push({ key, url, name: file.originalName, size: file.size, mimeType: file.mimeType })
160
+ }
161
+ return results
162
+ }
163
+
164
+ /**
165
+ * Cancel a still-pending upload — delete the file from storage immediately.
166
+ * Used by the editor when the user removes a file *before* saving the form.
167
+ *
168
+ * Returns 204 if the cancel succeeded, 404 if the key is not pending.
169
+ * The "not pending" branch protects already-persisted files: once a file
170
+ * has been confirmed via the action hooks it can no longer be removed via
171
+ * this endpoint — only via the regular edit/delete actions.
172
+ */
173
+ @Delete('upload')
174
+ @HttpCode(204)
175
+ async cancel(
176
+ @Param('resourceId') resourceId: string,
177
+ @Query('field') field: string,
178
+ @Query('key') key: string,
179
+ ): Promise<void> {
180
+ if (!key) throw new BadRequestException('Missing "key" query parameter')
181
+ // Validate that the resource/field is actually upload-enabled. This avoids
182
+ // leaking the existence of arbitrary resource ids to unauthenticated callers
183
+ // (the auth guard already requires authentication).
184
+ this.resolveProperty(resourceId, field)
185
+ const cancelled = await PendingUploadsRegistry.cancel(key)
186
+ if (!cancelled) {
187
+ throw new NotFoundException('Key is not pending — already saved or unknown')
188
+ }
189
+ }
190
+
191
+ // ─── Internals ─────────────────────────────────────────────────────────────
192
+
193
+ /** Resolve the resource + property and ensure it is wired to an upload provider. */
194
+ private resolveProperty(
195
+ resourceId: string,
196
+ field: string,
197
+ ): { providerId: string; registered: NonNullable<ReturnType<typeof UploadProviderRegistry.get>> } {
198
+ let resource: ReturnType<typeof this.admin.findResource>
199
+ try {
200
+ resource = this.admin.findResource(resourceId)
201
+ } catch (err) {
202
+ if (err instanceof ResourceNotFoundError) throw new NotFoundException(err.message)
203
+ if (err instanceof ForbiddenError) throw new ForbiddenException(err.message)
204
+ throw err
205
+ }
206
+
207
+ const decorator = resource.decorate()
208
+ const prop = decorator.getPropertyByKey(field)
209
+ if (!prop) {
210
+ throw new NotFoundException(`Property "${field}" not found on resource "${resourceId}"`)
211
+ }
212
+
213
+ const propJson = prop.toJSON()
214
+ const providerId = propJson.custom?.uploadProviderId as string | undefined
215
+ if (!providerId) {
216
+ throw new BadRequestException(
217
+ `Property "${field}" on resource "${resourceId}" is not configured for upload. ` +
218
+ 'Apply uploadFeature() to the resource.',
219
+ )
220
+ }
221
+
222
+ const registered = UploadProviderRegistry.get(providerId)
223
+ if (!registered) {
224
+ throw new InternalServerErrorException(`Upload provider "${providerId}" is not registered.`)
225
+ }
226
+ return { providerId, registered }
227
+ }
228
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * ModernAdminUploadModule — registers `UploadController` plus the sweeper
3
+ * background task that purges orphaned pending uploads.
4
+ *
5
+ * Import alongside `ModernAdminModule.forRoot()` in the host application:
6
+ *
7
+ * ```ts
8
+ * @Module({
9
+ * imports: [
10
+ * ModernAdminModule.forRoot({ global: true, ... }),
11
+ * ModernAdminUploadModule.forRoot({ pendingTtlMs: 60 * 60 * 1000 }),
12
+ * // feature modules that use uploadFeature() ...
13
+ * ],
14
+ * })
15
+ * export class AdminModule {}
16
+ * ```
17
+ *
18
+ * The module depends on `MODERN_ADMIN` and `ModernAdminAuthGuard` being
19
+ * available in the DI tree, which is satisfied when `ModernAdminModule.forRoot`
20
+ * is registered with `global: true` (the recommended default).
21
+ */
22
+
23
+ import { type DynamicModule, Module } from '@nestjs/common'
24
+ import { UploadController } from './upload.controller.js'
25
+ import { UploadSweeperService } from './upload-sweeper.service.js'
26
+ import { UPLOAD_MODULE_OPTIONS, type ModernAdminUploadModuleOptions } from './upload.tokens.js'
27
+
28
+ export type { ModernAdminUploadModuleOptions } from './upload.tokens.js'
29
+
30
+ @Module({})
31
+ export class ModernAdminUploadModule {
32
+ static forRoot(options: ModernAdminUploadModuleOptions = {}): DynamicModule {
33
+ return {
34
+ module: ModernAdminUploadModule,
35
+ global: options.global ?? false,
36
+ controllers: [UploadController],
37
+ providers: [
38
+ { provide: UPLOAD_MODULE_OPTIONS, useValue: options },
39
+ UploadSweeperService,
40
+ ],
41
+ exports: [UPLOAD_MODULE_OPTIONS],
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * DI tokens + option types shared between `ModernAdminUploadModule`,
3
+ * `UploadController`, and the sweeper service.
4
+ */
5
+
6
+ export const UPLOAD_MODULE_OPTIONS = Symbol.for('modern-admin.upload.module-options')
7
+
8
+ export interface ModernAdminUploadModuleOptions {
9
+ /** Mark this module as global. Defaults to false. */
10
+ global?: boolean
11
+ /**
12
+ * TTL applied to freshly uploaded files before the sweeper deletes them.
13
+ * Default: 1 hour. Files are confirmed (TTL becomes irrelevant) as soon
14
+ * as the parent record is saved, via the action hooks installed by
15
+ * `uploadFeature`.
16
+ */
17
+ pendingTtlMs?: number
18
+ /**
19
+ * Interval at which the sweeper runs. Default: 5 minutes. Set to `0` to
20
+ * disable the periodic sweeper entirely (the registry still works — entries
21
+ * just never expire).
22
+ */
23
+ sweepIntervalMs?: number
24
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * PendingUploadsRegistry — tracks files that were uploaded to storage but have
3
+ * not yet been "confirmed" by saving the parent record.
4
+ *
5
+ * Why
6
+ * ---
7
+ * Uploads happen *before* the form is submitted (the user picks a file, the
8
+ * editor calls `POST /upload`, gets back a key, stores it in form state). If
9
+ * the user then abandons the form (closes the tab, navigates away, server
10
+ * rejects the create payload, etc.), the file ends up orphaned in storage.
11
+ *
12
+ * How
13
+ * ---
14
+ * - The controller calls `track(key, providerId, ttlMs)` after every successful
15
+ * upload, recording an expiry timestamp.
16
+ * - The action hooks installed by `uploadFeature` call `confirm(keys)` from
17
+ * `new.after` / `edit.after` once the record is saved, removing the keys
18
+ * from the pending set so they will not be swept.
19
+ * - The user-initiated `DELETE /upload?…` endpoint calls `cancel(key)` to
20
+ * immediately remove the file from storage when the user removes a freshly
21
+ * uploaded file *before* saving.
22
+ * - A periodic sweeper started by `ModernAdminUploadModule.forRoot()` calls
23
+ * `sweep()` to delete files whose pending entry has expired. This handles
24
+ * abandoned forms, browser crashes, and any other path that bypasses
25
+ * client-side cleanup.
26
+ *
27
+ * The registry is a process-level singleton (same pattern as
28
+ * `UploadProviderRegistry`).
29
+ *
30
+ * TODO(roadmap): swap the in-process `Map` for a Redis-backed store and move
31
+ * the sweeper driver (`UploadSweeperService`) onto BullMQ. This is required
32
+ * for multi-instance deployments where one Nest replica serves the upload
33
+ * request and a different replica processes the form submission — without a
34
+ * shared store the second replica would not see the pending entry, the
35
+ * confirm hook would no-op, and the file would be swept on the originator.
36
+ * BullMQ also gives us crash-safe scheduling and retries.
37
+ */
38
+
39
+ import { UploadProviderRegistry } from './registry.js'
40
+
41
+ interface PendingEntry {
42
+ /** Provider id used to look up the upload provider for `delete()`. */
43
+ providerId: string
44
+ /** Epoch ms after which the entry is eligible for sweep. */
45
+ expiresAt: number
46
+ }
47
+
48
+ const _pending = new Map<string, PendingEntry>()
49
+
50
+ export const PendingUploadsRegistry = {
51
+ /** Mark `key` as a freshly-uploaded, unconfirmed file. */
52
+ track(key: string, providerId: string, ttlMs: number): void {
53
+ _pending.set(key, { providerId, expiresAt: Date.now() + ttlMs })
54
+ },
55
+
56
+ /** Whether `key` is currently in the pending set (helper for tests / cancel). */
57
+ has(key: string): boolean {
58
+ return _pending.has(key)
59
+ },
60
+
61
+ /** Confirm one or more keys — they leave pending without being deleted. */
62
+ confirm(keys: ReadonlyArray<string>): void {
63
+ for (const k of keys) _pending.delete(k)
64
+ },
65
+
66
+ /**
67
+ * Cancel a single pending key — deletes the file from storage and removes
68
+ * the entry. No-op if the key is not pending (already confirmed or unknown).
69
+ */
70
+ async cancel(key: string): Promise<boolean> {
71
+ const entry = _pending.get(key)
72
+ if (!entry) return false
73
+ _pending.delete(key)
74
+ const cfg = UploadProviderRegistry.get(entry.providerId)
75
+ if (!cfg) return false
76
+ try {
77
+ await cfg.provider.delete(key)
78
+ } catch {
79
+ // Non-fatal — best-effort cleanup.
80
+ }
81
+ return true
82
+ },
83
+
84
+ /**
85
+ * Sweep expired entries — delete each from storage and from the registry.
86
+ * Returns the number of files swept.
87
+ */
88
+ async sweep(now: number = Date.now()): Promise<number> {
89
+ let swept = 0
90
+ for (const [key, entry] of _pending) {
91
+ if (entry.expiresAt <= now) {
92
+ _pending.delete(key)
93
+ const cfg = UploadProviderRegistry.get(entry.providerId)
94
+ if (!cfg) continue
95
+ try {
96
+ await cfg.provider.delete(key)
97
+ swept++
98
+ } catch {
99
+ // Non-fatal.
100
+ }
101
+ }
102
+ }
103
+ return swept
104
+ },
105
+
106
+ /** For test cleanup. */
107
+ clear(): void {
108
+ _pending.clear()
109
+ },
110
+
111
+ /** Number of currently-pending entries (for tests / introspection). */
112
+ size(): number {
113
+ return _pending.size
114
+ },
115
+ }