@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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalUploadProvider — stores files on the local filesystem.
|
|
3
|
+
*
|
|
4
|
+
* Files are written to `uploadDir` with a UUID-based filename to avoid
|
|
5
|
+
* collisions. Set `baseUrl` to the public URL prefix where the directory is
|
|
6
|
+
* served as static files (e.g. `'/uploads'` or `'http://localhost:3000/uploads'`).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* new LocalUploadProvider({ uploadDir: './public/uploads', baseUrl: '/uploads' })
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdir, writeFile, unlink } from 'node:fs/promises'
|
|
13
|
+
import { join, extname, dirname } from 'node:path'
|
|
14
|
+
import { uuidv7 } from '@modern-admin/core'
|
|
15
|
+
import type { IUploadProvider, UploadedFile } from '../types.js'
|
|
16
|
+
|
|
17
|
+
export interface LocalUploadOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Absolute or process-relative path to the directory where files are stored.
|
|
20
|
+
* Created automatically on first upload.
|
|
21
|
+
*/
|
|
22
|
+
uploadDir: string
|
|
23
|
+
/**
|
|
24
|
+
* Public URL prefix (without trailing slash) where uploaded files can be
|
|
25
|
+
* accessed. Used to construct the `url` returned by the upload endpoint and
|
|
26
|
+
* the `urlTemplate` for the frontend.
|
|
27
|
+
*
|
|
28
|
+
* @example '/uploads'
|
|
29
|
+
* @example 'https://static.example.com/uploads'
|
|
30
|
+
*/
|
|
31
|
+
baseUrl?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class LocalUploadProvider implements IUploadProvider {
|
|
35
|
+
constructor(private readonly options: LocalUploadOptions) {}
|
|
36
|
+
|
|
37
|
+
async upload(file: UploadedFile, key?: string): Promise<string> {
|
|
38
|
+
const resolvedKey = key ?? `${uuidv7()}${extname(file.originalName)}`
|
|
39
|
+
const dest = join(this.options.uploadDir, resolvedKey)
|
|
40
|
+
// Create the full directory tree (handles nested keys like 'avatars/2024/01/uuid.jpg').
|
|
41
|
+
await mkdir(dirname(dest), { recursive: true })
|
|
42
|
+
await writeFile(dest, file.buffer)
|
|
43
|
+
return resolvedKey
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getUrl(key: string): string {
|
|
47
|
+
const base = this.options.baseUrl ?? '/uploads'
|
|
48
|
+
return `${base}/${key}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async delete(key: string): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
await unlink(join(this.options.uploadDir, key))
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
urlTemplate(): string {
|
|
60
|
+
const base = this.options.baseUrl ?? '/uploads'
|
|
61
|
+
return `${base}/{key}`
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3UploadProvider — stores files in an AWS S3 bucket (or any S3-compatible
|
|
3
|
+
* service such as MinIO, Cloudflare R2, DigitalOcean Spaces, etc.).
|
|
4
|
+
*
|
|
5
|
+
* Requires `@aws-sdk/client-s3` to be installed in the host project.
|
|
6
|
+
* For streaming multipart upload of large files, also install `@aws-sdk/lib-storage`.
|
|
7
|
+
* For pre-signed URLs (private buckets), install `@aws-sdk/s3-request-presigner`.
|
|
8
|
+
*
|
|
9
|
+
* @example AWS S3 — public bucket
|
|
10
|
+
* new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1', acl: 'public-read' })
|
|
11
|
+
*
|
|
12
|
+
* @example AWS S3 — private bucket with pre-signed URLs (60 min expiry)
|
|
13
|
+
* new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1', signed: { expiresIn: 3600 } })
|
|
14
|
+
*
|
|
15
|
+
* @example MinIO / custom endpoint
|
|
16
|
+
* new S3UploadProvider({
|
|
17
|
+
* bucket: 'my-bucket',
|
|
18
|
+
* region: 'us-east-1',
|
|
19
|
+
* endpoint: 'http://localhost:9000',
|
|
20
|
+
* forcePathStyle: true,
|
|
21
|
+
* publicBaseUrl: 'http://localhost:9000/my-bucket',
|
|
22
|
+
* })
|
|
23
|
+
*
|
|
24
|
+
* @example Inject a pre-configured S3Client (share across providers)
|
|
25
|
+
* const s3 = new S3Client({ region: 'us-east-1' })
|
|
26
|
+
* new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1' }, s3)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { extname } from 'node:path'
|
|
30
|
+
import { uuidv7 } from '@modern-admin/core'
|
|
31
|
+
import type { IUploadProvider, UploadedFile } from '../types.js'
|
|
32
|
+
|
|
33
|
+
export interface S3UploadOptions {
|
|
34
|
+
/** S3 bucket name. */
|
|
35
|
+
bucket: string
|
|
36
|
+
/** AWS region (e.g. `'us-east-1'`). */
|
|
37
|
+
region: string
|
|
38
|
+
/** AWS access key id. Falls back to environment / credential chain. */
|
|
39
|
+
accessKeyId?: string
|
|
40
|
+
/** AWS secret access key. Falls back to environment / credential chain. */
|
|
41
|
+
secretAccessKey?: string
|
|
42
|
+
/**
|
|
43
|
+
* Custom endpoint for S3-compatible services.
|
|
44
|
+
* @example 'http://localhost:9000'
|
|
45
|
+
* @example 'https://nyc3.digitaloceanspaces.com'
|
|
46
|
+
*/
|
|
47
|
+
endpoint?: string
|
|
48
|
+
/** Force path-style URLs (required for some MinIO / custom endpoint setups). */
|
|
49
|
+
forcePathStyle?: boolean
|
|
50
|
+
/** Optional key prefix / "folder". Without trailing slash. */
|
|
51
|
+
prefix?: string
|
|
52
|
+
/**
|
|
53
|
+
* Canned ACL applied to every uploaded object, e.g. `'public-read'`.
|
|
54
|
+
* Omit for private buckets — use `signed` instead.
|
|
55
|
+
*/
|
|
56
|
+
acl?: string
|
|
57
|
+
/**
|
|
58
|
+
* Override the public base URL used for `getUrl()` and `urlTemplate()`.
|
|
59
|
+
* Useful when files are served via CloudFront or a custom CDN.
|
|
60
|
+
* Without trailing slash.
|
|
61
|
+
* @example 'https://cdn.example.com'
|
|
62
|
+
*/
|
|
63
|
+
publicBaseUrl?: string
|
|
64
|
+
/**
|
|
65
|
+
* Generate pre-signed URLs instead of public URLs.
|
|
66
|
+
* Required for private S3 buckets. Needs `@aws-sdk/s3-request-presigner`.
|
|
67
|
+
*
|
|
68
|
+
* - `true` uses the default expiry (3600 s / 1 hour).
|
|
69
|
+
* - Pass `{ expiresIn: seconds }` to customise.
|
|
70
|
+
*
|
|
71
|
+
* When `signed` is set, `urlTemplate()` is not implemented (each URL is
|
|
72
|
+
* unique and time-limited) and `getUrl()` is async.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* signed: true // 1-hour pre-signed URLs
|
|
76
|
+
* signed: { expiresIn: 60 * 60 * 24 } // 24-hour pre-signed URLs
|
|
77
|
+
*/
|
|
78
|
+
signed?: boolean | { expiresIn?: number }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class S3UploadProvider implements IUploadProvider {
|
|
82
|
+
|
|
83
|
+
private _client: any = null
|
|
84
|
+
|
|
85
|
+
constructor(
|
|
86
|
+
private readonly options: S3UploadOptions,
|
|
87
|
+
// Accept a pre-configured S3Client instance (share across providers /
|
|
88
|
+
// inject in tests). When omitted the provider creates its own client.
|
|
89
|
+
|
|
90
|
+
private readonly injectedClient?: any,
|
|
91
|
+
) {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
private async client(): Promise<any> {
|
|
95
|
+
if (this.injectedClient) return this.injectedClient
|
|
96
|
+
if (this._client) return this._client
|
|
97
|
+
let S3Client: unknown
|
|
98
|
+
try {
|
|
99
|
+
const mod = await import('@aws-sdk/client-s3' as string)
|
|
100
|
+
S3Client = (mod as { S3Client: unknown }).S3Client
|
|
101
|
+
} catch {
|
|
102
|
+
throw new Error(
|
|
103
|
+
'[modern-admin/feature-upload] S3UploadProvider requires @aws-sdk/client-s3. ' +
|
|
104
|
+
'Install it: bun add @aws-sdk/client-s3',
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
const cfg: Record<string, unknown> = {
|
|
108
|
+
region: this.options.region,
|
|
109
|
+
...(this.options.endpoint ? { endpoint: this.options.endpoint } : {}),
|
|
110
|
+
...(this.options.forcePathStyle ? { forcePathStyle: true } : {}),
|
|
111
|
+
}
|
|
112
|
+
if (this.options.accessKeyId && this.options.secretAccessKey) {
|
|
113
|
+
cfg.credentials = {
|
|
114
|
+
accessKeyId: this.options.accessKeyId,
|
|
115
|
+
secretAccessKey: this.options.secretAccessKey,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
this._client = new (S3Client as new (cfg: Record<string, unknown>) => unknown)(cfg)
|
|
119
|
+
return this._client
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async upload(file: UploadedFile, key?: string): Promise<string> {
|
|
123
|
+
const c = await this.client()
|
|
124
|
+
const ext = extname(file.originalName)
|
|
125
|
+
const prefix = this.options.prefix ? `${this.options.prefix}/` : ''
|
|
126
|
+
const resolvedKey = key ?? `${prefix}${uuidv7()}${ext}`
|
|
127
|
+
|
|
128
|
+
// Prefer @aws-sdk/lib-storage for streaming multipart uploads (large files).
|
|
129
|
+
// Fall back to PutObjectCommand if lib-storage is not installed.
|
|
130
|
+
try {
|
|
131
|
+
const libStorage = await import('@aws-sdk/lib-storage' as string)
|
|
132
|
+
const Upload = (libStorage as { Upload: new (i: Record<string, unknown>) => { done(): Promise<unknown> } }).Upload
|
|
133
|
+
const input: Record<string, unknown> = {
|
|
134
|
+
Bucket: this.options.bucket,
|
|
135
|
+
Key: resolvedKey,
|
|
136
|
+
Body: file.buffer,
|
|
137
|
+
ContentType: file.mimeType,
|
|
138
|
+
}
|
|
139
|
+
if (this.options.acl) input.ACL = this.options.acl
|
|
140
|
+
const uploader = new Upload({ client: c, params: input })
|
|
141
|
+
await uploader.done()
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// lib-storage not installed — fall back to PutObjectCommand.
|
|
144
|
+
if ((err as { code?: string }).code === 'ERR_MODULE_NOT_FOUND' ||
|
|
145
|
+
String(err).includes('Cannot find module')) {
|
|
146
|
+
const sdk = await import('@aws-sdk/client-s3' as string)
|
|
147
|
+
const PutObjectCommand = (sdk as { PutObjectCommand: new (i: Record<string, unknown>) => unknown }).PutObjectCommand
|
|
148
|
+
const input: Record<string, unknown> = {
|
|
149
|
+
Bucket: this.options.bucket,
|
|
150
|
+
Key: resolvedKey,
|
|
151
|
+
Body: file.buffer,
|
|
152
|
+
ContentType: file.mimeType,
|
|
153
|
+
ContentLength: file.size,
|
|
154
|
+
}
|
|
155
|
+
if (this.options.acl) input.ACL = this.options.acl
|
|
156
|
+
await (c as { send: (cmd: unknown) => Promise<void> }).send(new PutObjectCommand(input))
|
|
157
|
+
} else {
|
|
158
|
+
throw err
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return resolvedKey
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async getUrl(key: string): Promise<string> {
|
|
166
|
+
if (this.options.signed) {
|
|
167
|
+
return this.signedUrl(key)
|
|
168
|
+
}
|
|
169
|
+
return `${this.publicBaseUrl()}/${key}`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async delete(key: string): Promise<void> {
|
|
173
|
+
try {
|
|
174
|
+
const sdk = await import('@aws-sdk/client-s3' as string)
|
|
175
|
+
const c = await this.client()
|
|
176
|
+
const DeleteObjectCommand = (sdk as { DeleteObjectCommand: new (i: Record<string, unknown>) => unknown }).DeleteObjectCommand
|
|
177
|
+
await (c as { send: (cmd: unknown) => Promise<void> }).send(
|
|
178
|
+
new DeleteObjectCommand({ Bucket: this.options.bucket, Key: key }),
|
|
179
|
+
)
|
|
180
|
+
} catch {
|
|
181
|
+
// Ignore — file may not exist.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* URL template for the frontend. Only available when `signed` is NOT set
|
|
187
|
+
* (public buckets). For private buckets each URL is unique + time-limited,
|
|
188
|
+
* so the frontend must call the upload endpoint to get a fresh URL.
|
|
189
|
+
*/
|
|
190
|
+
urlTemplate(): string | undefined {
|
|
191
|
+
if (this.options.signed) return undefined
|
|
192
|
+
return `${this.publicBaseUrl()}/{key}`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async signedUrl(key: string): Promise<string> {
|
|
196
|
+
let getSignedUrl: unknown
|
|
197
|
+
try {
|
|
198
|
+
const mod = await import('@aws-sdk/s3-request-presigner' as string)
|
|
199
|
+
getSignedUrl = (mod as { getSignedUrl: unknown }).getSignedUrl
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error(
|
|
202
|
+
'[modern-admin/feature-upload] Signed URLs require @aws-sdk/s3-request-presigner. ' +
|
|
203
|
+
'Install it: bun add @aws-sdk/s3-request-presigner',
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
const sdk = await import('@aws-sdk/client-s3' as string)
|
|
207
|
+
const c = await this.client()
|
|
208
|
+
const GetObjectCommand = (sdk as { GetObjectCommand: new (i: Record<string, unknown>) => unknown }).GetObjectCommand
|
|
209
|
+
const expiresIn =
|
|
210
|
+
typeof this.options.signed === 'object'
|
|
211
|
+
? (this.options.signed.expiresIn ?? 3600)
|
|
212
|
+
: 3600
|
|
213
|
+
|
|
214
|
+
return (getSignedUrl as (
|
|
215
|
+
client: unknown,
|
|
216
|
+
command: unknown,
|
|
217
|
+
opts: { expiresIn: number },
|
|
218
|
+
) => Promise<string>)(c, new GetObjectCommand({ Bucket: this.options.bucket, Key: key }), { expiresIn })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private publicBaseUrl(): string {
|
|
222
|
+
if (this.options.publicBaseUrl) return this.options.publicBaseUrl
|
|
223
|
+
if (this.options.endpoint) return `${this.options.endpoint}/${this.options.bucket}`
|
|
224
|
+
return `https://${this.options.bucket}.s3.${this.options.region}.amazonaws.com`
|
|
225
|
+
}
|
|
226
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-level upload provider registry.
|
|
3
|
+
*
|
|
4
|
+
* `uploadFeature()` registers upload configs here (keyed by a generated id) at
|
|
5
|
+
* feature application time. The NestJS `UploadController` looks up configs by
|
|
6
|
+
* the id stored in the property's `custom.uploadProviderId` field.
|
|
7
|
+
*
|
|
8
|
+
* The registry is intentionally a module-level singleton so it is available
|
|
9
|
+
* before the NestJS DI container is initialised — the same pattern used by
|
|
10
|
+
* `ModernAdminFeatureRegistry` in `@modern-admin/nest`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { IUploadProvider } from './types.js'
|
|
14
|
+
|
|
15
|
+
/** Everything stored per upload property registration. */
|
|
16
|
+
export interface RegisteredUploadConfig {
|
|
17
|
+
provider: IUploadProvider
|
|
18
|
+
/** Optional custom key generator (from `UploadPropertyConfig.uploadPath`). */
|
|
19
|
+
uploadPath?: (filename: string) => string
|
|
20
|
+
/** True for multi-file properties — controller will accept N files per request. */
|
|
21
|
+
isArray?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const _registry = new Map<string, RegisteredUploadConfig>()
|
|
25
|
+
|
|
26
|
+
export const UploadProviderRegistry = {
|
|
27
|
+
register(id: string, config: RegisteredUploadConfig): void {
|
|
28
|
+
_registry.set(id, config)
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
get(id: string): RegisteredUploadConfig | undefined {
|
|
32
|
+
return _registry.get(id)
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
/** For test cleanup. */
|
|
36
|
+
clear(): void {
|
|
37
|
+
_registry.clear()
|
|
38
|
+
},
|
|
39
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the upload feature plugin.
|
|
3
|
+
*
|
|
4
|
+
* `IUploadProvider` is the single port contract that upload backends must
|
|
5
|
+
* implement. `LocalUploadProvider` and `S3UploadProvider` ship as built-in
|
|
6
|
+
* adapters; custom providers only need to implement these four methods.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Parsed file data received from a multipart request. */
|
|
10
|
+
export interface UploadedFile {
|
|
11
|
+
/** Original filename from the client. */
|
|
12
|
+
originalName: string
|
|
13
|
+
/** MIME type reported by the client (e.g. 'image/jpeg'). */
|
|
14
|
+
mimeType: string
|
|
15
|
+
/** File size in bytes. */
|
|
16
|
+
size: number
|
|
17
|
+
/** Raw file bytes. */
|
|
18
|
+
buffer: Buffer
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Metadata returned by the upload endpoint. */
|
|
22
|
+
export interface UploadedFileInfo {
|
|
23
|
+
/** Storage key (relative path or object key). Stored in the DB field. */
|
|
24
|
+
key: string
|
|
25
|
+
/** Public URL for browser display / download. */
|
|
26
|
+
url: string
|
|
27
|
+
/** Original filename. */
|
|
28
|
+
name: string
|
|
29
|
+
/** Size in bytes. */
|
|
30
|
+
size: number
|
|
31
|
+
/** MIME type. */
|
|
32
|
+
mimeType: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Upload provider port. Implement this interface to add a new storage backend.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* class GcsUploadProvider implements IUploadProvider {
|
|
40
|
+
* async upload(file) { ... }
|
|
41
|
+
* getUrl(key) { ... }
|
|
42
|
+
* async delete(key) { ... }
|
|
43
|
+
* }
|
|
44
|
+
*/
|
|
45
|
+
export interface IUploadProvider {
|
|
46
|
+
/**
|
|
47
|
+
* Upload a file and return its storage key.
|
|
48
|
+
* The key is the value that will be persisted in the database field.
|
|
49
|
+
*
|
|
50
|
+
* @param file Parsed file data from the multipart request.
|
|
51
|
+
* @param key Optional pre-computed storage key (from `uploadPath`). When
|
|
52
|
+
* omitted the provider generates a key internally (UUID + extension).
|
|
53
|
+
*/
|
|
54
|
+
upload(file: UploadedFile, key?: string): Promise<string>
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compute the public URL for a stored key. May be sync or async.
|
|
58
|
+
* Used both by the upload endpoint (to return the URL) and by the frontend
|
|
59
|
+
* URL template when `urlTemplate()` is not implemented.
|
|
60
|
+
*/
|
|
61
|
+
getUrl(key: string): string | Promise<string>
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Delete the file identified by `key`. Must not throw if the file does not
|
|
65
|
+
* exist (e.g. was already deleted or was never stored).
|
|
66
|
+
*/
|
|
67
|
+
delete(key: string): Promise<void>
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Optional URL template string for the frontend to construct display URLs
|
|
71
|
+
* from stored keys without calling the backend.
|
|
72
|
+
* Use `{key}` as the placeholder, e.g. `'https://cdn.example.com/{key}'`.
|
|
73
|
+
* Return `undefined` (or omit the method) when URLs cannot be statically
|
|
74
|
+
* computed (e.g. signed/pre-signed S3 URLs — each URL is unique and time-limited).
|
|
75
|
+
*/
|
|
76
|
+
urlTemplate?(): string | undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Per-property upload configuration passed to `uploadFeature()`. */
|
|
80
|
+
export interface UploadPropertyConfig {
|
|
81
|
+
/** Storage provider for this property. */
|
|
82
|
+
provider: IUploadProvider
|
|
83
|
+
/**
|
|
84
|
+
* Allowed MIME type patterns (same syntax as the HTML `accept` attribute).
|
|
85
|
+
* Examples: `['image/*']`, `['image/jpeg', 'application/pdf']`.
|
|
86
|
+
* Enforcement is advisory on the frontend; the controller does not re-validate.
|
|
87
|
+
*/
|
|
88
|
+
mimeTypes?: string[]
|
|
89
|
+
/** Maximum upload size in bytes. Advisory (frontend warning only). */
|
|
90
|
+
maxSize?: number
|
|
91
|
+
/**
|
|
92
|
+
* Treat the property as an array of file keys (multi-file upload).
|
|
93
|
+
* When true, the property is stored as `string[]`, the editor allows
|
|
94
|
+
* uploading multiple files (a single multipart request can include several),
|
|
95
|
+
* and the action hooks diff arrays on edit and delete every key on delete.
|
|
96
|
+
*/
|
|
97
|
+
isArray?: boolean
|
|
98
|
+
/**
|
|
99
|
+
* Custom storage key generator. Receives the original filename from the
|
|
100
|
+
* client and returns the key to use for storage.
|
|
101
|
+
*
|
|
102
|
+
* Use this to organise files into sub-directories, include the resource
|
|
103
|
+
* name, add timestamps, etc. The provider's internal UUID generator is
|
|
104
|
+
* used when this is omitted.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* // avatars/2024/01/uuid.jpg
|
|
108
|
+
* uploadPath: (filename) => {
|
|
109
|
+
* const ext = filename.split('.').pop()
|
|
110
|
+
* const d = new Date()
|
|
111
|
+
* return `avatars/${d.getFullYear()}/${String(d.getMonth()+1).padStart(2,'0')}/${uuidv7()}.${ext}`
|
|
112
|
+
* }
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* // resource-specific prefix
|
|
116
|
+
* uploadPath: (filename) => `products/images/${uuidv7()}-${filename}`
|
|
117
|
+
*/
|
|
118
|
+
uploadPath?: (filename: string) => string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Options passed to `uploadFeature()`. */
|
|
122
|
+
export interface UploadFeatureOptions {
|
|
123
|
+
/**
|
|
124
|
+
* Map from property path to its upload configuration.
|
|
125
|
+
* Each entry makes the property a `type: 'file'` field wired to the
|
|
126
|
+
* specified provider.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* uploadFeature({
|
|
130
|
+
* properties: {
|
|
131
|
+
* avatar: { provider: new LocalUploadProvider({ uploadDir: './uploads', baseUrl: '/uploads' }) },
|
|
132
|
+
* resume: { provider: new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1' }), mimeTypes: ['application/pdf'] },
|
|
133
|
+
* },
|
|
134
|
+
* })
|
|
135
|
+
*/
|
|
136
|
+
properties: Record<string, UploadPropertyConfig>
|
|
137
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `uploadFeature` — resource plugin that wires file upload into any resource.
|
|
3
|
+
*
|
|
4
|
+
* Returns a `FeatureFn` that, when applied to a resource's `ResourceOptions`,
|
|
5
|
+
* marks the configured properties as `type: 'file'` (with `isArray` when
|
|
6
|
+
* configured), registers the upload providers in `UploadProviderRegistry`, and
|
|
7
|
+
* installs action hooks that:
|
|
8
|
+
*
|
|
9
|
+
* - on `new.after` / `edit.after` — confirm freshly-uploaded keys against
|
|
10
|
+
* `PendingUploadsRegistry` so the orphan sweeper leaves them alone;
|
|
11
|
+
* - on `edit.after` — delete files whose key was replaced or removed
|
|
12
|
+
* (single-value: old !== new; array: keys present in old but missing
|
|
13
|
+
* from new);
|
|
14
|
+
* - on `delete.after` — delete every file referenced by the deleted record.
|
|
15
|
+
*
|
|
16
|
+
* Hooks are **chained**, not replaced: if the incoming `ResourceOptions`
|
|
17
|
+
* already has hooks (e.g. from another feature), the upload hooks are
|
|
18
|
+
* appended so all hooks run in order.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* uploadFeature({
|
|
22
|
+
* properties: {
|
|
23
|
+
* thumbnail: { provider: localProvider, mimeTypes: ['image/*'] },
|
|
24
|
+
* gallery: { provider: localProvider, isArray: true, mimeTypes: ['image/*'] },
|
|
25
|
+
* },
|
|
26
|
+
* })
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { uuidv7, type ActionRequest, type ActionResponse, type FeatureFn, type ResourceOptions } from '@modern-admin/core'
|
|
30
|
+
import type { UploadFeatureOptions, UploadPropertyConfig } from './types.js'
|
|
31
|
+
import { UploadProviderRegistry } from './registry.js'
|
|
32
|
+
import { PendingUploadsRegistry } from './pending-registry.js'
|
|
33
|
+
|
|
34
|
+
interface RegisteredProp {
|
|
35
|
+
providerId: string
|
|
36
|
+
config: UploadPropertyConfig
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Hook chaining ────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
type HookFn = (
|
|
42
|
+
response: ActionResponse,
|
|
43
|
+
request: ActionRequest,
|
|
44
|
+
context: unknown,
|
|
45
|
+
) => ActionResponse | Promise<ActionResponse>
|
|
46
|
+
|
|
47
|
+
/** Normalise a hook value (fn | fn[] | undefined) into an array. */
|
|
48
|
+
function toArray(hook: unknown): HookFn[] {
|
|
49
|
+
if (!hook) return []
|
|
50
|
+
return Array.isArray(hook) ? (hook as HookFn[]) : [hook as HookFn]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Merge `after` hooks from an existing action override with new hooks,
|
|
55
|
+
* producing an array that runs all of them in order.
|
|
56
|
+
* If `existing` already defines `after`, the new hooks are appended.
|
|
57
|
+
*/
|
|
58
|
+
function mergeAfterHook(
|
|
59
|
+
existing: Record<string, unknown> | undefined,
|
|
60
|
+
newHook: HookFn,
|
|
61
|
+
): HookFn[] {
|
|
62
|
+
const existing_after = toArray(existing?.after)
|
|
63
|
+
return [...existing_after, newHook]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Value helpers ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** Return non-empty file keys for the given value (handles single + array). */
|
|
69
|
+
function toKeys(value: unknown): string[] {
|
|
70
|
+
if (value == null || value === '') return []
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
return value.flatMap((v) => (v == null || v === '' ? [] : [String(v)]))
|
|
73
|
+
}
|
|
74
|
+
return [String(value)]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Feature function ─────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
export function uploadFeature(options: UploadFeatureOptions): FeatureFn {
|
|
80
|
+
// Register configs immediately — before the FeatureFn is called.
|
|
81
|
+
const registered = new Map<string, RegisteredProp>()
|
|
82
|
+
|
|
83
|
+
for (const [propPath, config] of Object.entries(options.properties)) {
|
|
84
|
+
const providerId = `up_${uuidv7().replace(/-/g, '')}`
|
|
85
|
+
UploadProviderRegistry.register(providerId, {
|
|
86
|
+
provider: config.provider,
|
|
87
|
+
uploadPath: config.uploadPath,
|
|
88
|
+
isArray: config.isArray ?? false,
|
|
89
|
+
})
|
|
90
|
+
registered.set(propPath, { providerId, config })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (resourceOptions: ResourceOptions): ResourceOptions => {
|
|
94
|
+
// --- Property overrides ---
|
|
95
|
+
const propOverrides: ResourceOptions['properties'] = {}
|
|
96
|
+
for (const [propPath, { providerId, config }] of registered) {
|
|
97
|
+
// S3 with signed URLs has no static URL template.
|
|
98
|
+
const urlTmpl = config.provider.urlTemplate?.() ?? null
|
|
99
|
+
propOverrides[propPath] = {
|
|
100
|
+
type: 'file',
|
|
101
|
+
...(config.isArray ? { isArray: true } : {}),
|
|
102
|
+
custom: {
|
|
103
|
+
uploadProviderId: providerId,
|
|
104
|
+
uploadUrlTemplate: urlTmpl,
|
|
105
|
+
uploadMimeTypes: config.mimeTypes ?? null,
|
|
106
|
+
uploadMaxSize: config.maxSize ?? null,
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- Action hooks ---
|
|
112
|
+
|
|
113
|
+
// After new: confirm every freshly-uploaded key against the pending registry.
|
|
114
|
+
const newAfterHook: HookFn = (
|
|
115
|
+
response: ActionResponse,
|
|
116
|
+
_request: ActionRequest,
|
|
117
|
+
_context: unknown,
|
|
118
|
+
): ActionResponse => {
|
|
119
|
+
const rec = response as { record?: { params?: Record<string, unknown> } }
|
|
120
|
+
const params = rec.record?.params ?? {}
|
|
121
|
+
const newKeys: string[] = []
|
|
122
|
+
for (const propPath of registered.keys()) {
|
|
123
|
+
newKeys.push(...toKeys(params[propPath]))
|
|
124
|
+
}
|
|
125
|
+
if (newKeys.length > 0) PendingUploadsRegistry.confirm(newKeys)
|
|
126
|
+
return response
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// After edit: delete keys that disappeared and confirm keys that arrived.
|
|
130
|
+
const editAfterHook: HookFn = async (
|
|
131
|
+
response: ActionResponse,
|
|
132
|
+
_request: ActionRequest,
|
|
133
|
+
context: unknown,
|
|
134
|
+
): Promise<ActionResponse> => {
|
|
135
|
+
const ctx = context as { record?: { get(path: string): unknown } }
|
|
136
|
+
const rec = response as { record?: { params?: Record<string, unknown> } }
|
|
137
|
+
const params = rec.record?.params ?? {}
|
|
138
|
+
for (const [propPath, { config }] of registered) {
|
|
139
|
+
const oldKeys = new Set(toKeys(ctx.record?.get(propPath)))
|
|
140
|
+
const newKeys = toKeys(params[propPath])
|
|
141
|
+
// Confirm new keys (possibly fresh uploads).
|
|
142
|
+
if (newKeys.length > 0) PendingUploadsRegistry.confirm(newKeys)
|
|
143
|
+
// Delete keys that were present before and are gone now.
|
|
144
|
+
const newSet = new Set(newKeys)
|
|
145
|
+
for (const k of oldKeys) {
|
|
146
|
+
if (!newSet.has(k)) {
|
|
147
|
+
try {
|
|
148
|
+
await config.provider.delete(k)
|
|
149
|
+
} catch {
|
|
150
|
+
// Non-fatal — don't break the action response.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return response
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// After delete: delete every file referenced by the (now-removed) record.
|
|
159
|
+
const deleteAfterHook: HookFn = async (
|
|
160
|
+
response: ActionResponse,
|
|
161
|
+
_request: ActionRequest,
|
|
162
|
+
context: unknown,
|
|
163
|
+
): Promise<ActionResponse> => {
|
|
164
|
+
const ctx = context as { record?: { get(path: string): unknown } }
|
|
165
|
+
for (const [propPath, { config }] of registered) {
|
|
166
|
+
const keys = toKeys(ctx.record?.get(propPath))
|
|
167
|
+
for (const k of keys) {
|
|
168
|
+
try {
|
|
169
|
+
await config.provider.delete(k)
|
|
170
|
+
} catch {
|
|
171
|
+
// Non-fatal.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return response
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Retrieve any existing action overrides so we can chain, not replace.
|
|
179
|
+
const existingActions = resourceOptions.actions as Record<string, Record<string, unknown>> | undefined
|
|
180
|
+
const existingNew = existingActions?.['new']
|
|
181
|
+
const existingEdit = existingActions?.['edit']
|
|
182
|
+
const existingDelete = existingActions?.['delete']
|
|
183
|
+
|
|
184
|
+
const actionOverrides = {
|
|
185
|
+
new: {
|
|
186
|
+
...existingNew,
|
|
187
|
+
after: mergeAfterHook(existingNew, newAfterHook),
|
|
188
|
+
},
|
|
189
|
+
edit: {
|
|
190
|
+
...existingEdit,
|
|
191
|
+
after: mergeAfterHook(existingEdit, editAfterHook),
|
|
192
|
+
},
|
|
193
|
+
delete: {
|
|
194
|
+
...existingDelete,
|
|
195
|
+
after: mergeAfterHook(existingDelete, deleteAfterHook),
|
|
196
|
+
},
|
|
197
|
+
} as ResourceOptions['actions']
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
...resourceOptions,
|
|
201
|
+
properties: { ...resourceOptions.properties, ...propOverrides },
|
|
202
|
+
actions: {
|
|
203
|
+
...(resourceOptions.actions ?? {}),
|
|
204
|
+
...actionOverrides,
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|