@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.
- package/dist/graphql.d.ts +36 -0
- package/dist/graphql.d.ts.map +1 -0
- package/dist/graphql.js +141 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/nest/index.d.ts +3 -0
- package/dist/nest/index.d.ts.map +1 -0
- package/dist/nest/index.js +4 -0
- package/dist/nest/index.js.map +1 -0
- package/dist/nest/upload-sweeper.service.d.ts +32 -0
- package/dist/nest/upload-sweeper.service.d.ts.map +1 -0
- package/dist/nest/upload-sweeper.service.js +68 -0
- package/dist/nest/upload-sweeper.service.js.map +1 -0
- package/dist/nest/upload.controller.d.ts +51 -0
- package/dist/nest/upload.controller.d.ts.map +1 -0
- package/dist/nest/upload.controller.js +220 -0
- package/dist/nest/upload.controller.js.map +1 -0
- package/dist/nest/upload.module.d.ts +28 -0
- package/dist/nest/upload.module.d.ts.map +1 -0
- package/dist/nest/upload.module.js +51 -0
- package/dist/nest/upload.module.js.map +1 -0
- package/dist/nest/upload.tokens.d.ts +23 -0
- package/dist/nest/upload.tokens.d.ts.map +1 -0
- package/dist/nest/upload.tokens.js +6 -0
- package/dist/nest/upload.tokens.js.map +1 -0
- package/dist/pending-registry.d.ts +60 -0
- package/dist/pending-registry.d.ts.map +1 -0
- package/dist/pending-registry.js +106 -0
- package/dist/pending-registry.js.map +1 -0
- package/dist/providers/local.d.ts +36 -0
- package/dist/providers/local.d.ts.map +1 -0
- package/dist/providers/local.js +44 -0
- package/dist/providers/local.js.map +1 -0
- package/dist/providers/s3.d.ts +96 -0
- package/dist/providers/s3.d.ts.map +1 -0
- package/dist/providers/s3.js +164 -0
- package/dist/providers/s3.js.map +1 -0
- package/dist/registry.d.ts +27 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +25 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/upload-feature.d.ts +31 -0
- package/dist/upload-feature.d.ts.map +1 -0
- package/dist/upload-feature.js +170 -0
- package/dist/upload-feature.js.map +1 -0
- package/package.json +94 -0
- package/src/graphql.ts +185 -0
- package/src/index.ts +22 -0
- package/src/nest/index.ts +4 -0
- package/src/nest/upload-sweeper.service.ts +52 -0
- package/src/nest/upload.controller.ts +228 -0
- package/src/nest/upload.module.ts +44 -0
- package/src/nest/upload.tokens.ts +24 -0
- package/src/pending-registry.ts +115 -0
- package/src/providers/local.ts +63 -0
- package/src/providers/s3.ts +226 -0
- package/src/registry.ts +39 -0
- package/src/types.ts +137 -0
- 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,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
|
+
}
|