@opensaas/stack-storage 0.1.1
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/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +426 -0
- package/LICENSE +21 -0
- package/README.md +425 -0
- package/dist/config/index.d.ts +19 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +25 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/types.d.ts +113 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/fields/index.d.ts +111 -0
- package/dist/fields/index.d.ts.map +1 -0
- package/dist/fields/index.js +237 -0
- package/dist/fields/index.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +2 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/local.d.ts +22 -0
- package/dist/providers/local.d.ts.map +1 -0
- package/dist/providers/local.js +64 -0
- package/dist/providers/local.js.map +1 -0
- package/dist/runtime/index.d.ts +75 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +157 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/utils/image.d.ts +18 -0
- package/dist/utils/image.d.ts.map +1 -0
- package/dist/utils/image.js +82 -0
- package/dist/utils/image.js.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/upload.d.ts +56 -0
- package/dist/utils/upload.d.ts.map +1 -0
- package/dist/utils/upload.js +74 -0
- package/dist/utils/upload.js.map +1 -0
- package/package.json +50 -0
- package/src/config/index.ts +30 -0
- package/src/config/types.ts +127 -0
- package/src/fields/index.ts +347 -0
- package/src/index.ts +14 -0
- package/src/providers/index.ts +1 -0
- package/src/providers/local.ts +85 -0
- package/src/runtime/index.ts +243 -0
- package/src/utils/image.ts +111 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/upload.ts +122 -0
- package/tests/image-utils.test.ts +498 -0
- package/tests/local-provider.test.ts +349 -0
- package/tests/upload-utils.test.ts +313 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import type { BaseFieldConfig } from '@opensaas/stack-core'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { ComponentType } from 'react'
|
|
4
|
+
import type { FileMetadata, ImageMetadata, ImageTransformationConfig } from '../config/types.js'
|
|
5
|
+
import type { FileValidationOptions } from '../utils/upload.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* File field configuration
|
|
9
|
+
*/
|
|
10
|
+
export interface FileFieldConfig extends BaseFieldConfig<FileMetadata | null, FileMetadata | null> {
|
|
11
|
+
type: 'file'
|
|
12
|
+
/** Name of the storage provider from config.storage */
|
|
13
|
+
storage: string
|
|
14
|
+
/** File validation options */
|
|
15
|
+
validation?: FileValidationOptions
|
|
16
|
+
/** Automatically delete file from storage when record is deleted */
|
|
17
|
+
cleanupOnDelete?: boolean
|
|
18
|
+
/** Automatically delete old file from storage when replaced with new file */
|
|
19
|
+
cleanupOnReplace?: boolean
|
|
20
|
+
/** UI options */
|
|
21
|
+
ui?: {
|
|
22
|
+
/** Custom component to use for rendering this field */
|
|
23
|
+
component?: ComponentType<unknown>
|
|
24
|
+
/** Custom field type name for component registry lookup */
|
|
25
|
+
fieldType?: string
|
|
26
|
+
/** Label for the field */
|
|
27
|
+
label?: string
|
|
28
|
+
/** Help text shown below the field */
|
|
29
|
+
helpText?: string
|
|
30
|
+
/** Placeholder text */
|
|
31
|
+
placeholder?: string
|
|
32
|
+
/** Additional UI options passed through to component */
|
|
33
|
+
[key: string]: unknown
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Image field configuration
|
|
39
|
+
*/
|
|
40
|
+
export interface ImageFieldConfig
|
|
41
|
+
extends BaseFieldConfig<ImageMetadata | null, ImageMetadata | null> {
|
|
42
|
+
type: 'image'
|
|
43
|
+
/** Name of the storage provider from config.storage */
|
|
44
|
+
storage: string
|
|
45
|
+
/** Image transformations to generate on upload */
|
|
46
|
+
transformations?: Record<string, ImageTransformationConfig>
|
|
47
|
+
/** File validation options */
|
|
48
|
+
validation?: FileValidationOptions
|
|
49
|
+
/** Automatically delete file from storage when record is deleted */
|
|
50
|
+
cleanupOnDelete?: boolean
|
|
51
|
+
/** Automatically delete old file from storage when replaced with new file */
|
|
52
|
+
cleanupOnReplace?: boolean
|
|
53
|
+
/** UI options */
|
|
54
|
+
ui?: {
|
|
55
|
+
/** Custom component to use for rendering this field */
|
|
56
|
+
component?: ComponentType<unknown>
|
|
57
|
+
/** Custom field type name for component registry lookup */
|
|
58
|
+
fieldType?: string
|
|
59
|
+
/** Label for the field */
|
|
60
|
+
label?: string
|
|
61
|
+
/** Help text shown below the field */
|
|
62
|
+
helpText?: string
|
|
63
|
+
/** Placeholder text */
|
|
64
|
+
placeholder?: string
|
|
65
|
+
/** Show image preview */
|
|
66
|
+
showPreview?: boolean
|
|
67
|
+
/** Preview size (width in pixels) */
|
|
68
|
+
previewSize?: number
|
|
69
|
+
/** Additional UI options passed through to component */
|
|
70
|
+
[key: string]: unknown
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Creates a file upload field
|
|
76
|
+
*
|
|
77
|
+
* Uses JSON field backing to store file metadata including filename, URL, size, MIME type, etc.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```typescript
|
|
81
|
+
* fields: {
|
|
82
|
+
* resume: file({
|
|
83
|
+
* storage: 'documents',
|
|
84
|
+
* validation: {
|
|
85
|
+
* maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
86
|
+
* acceptedMimeTypes: ['application/pdf']
|
|
87
|
+
* }
|
|
88
|
+
* })
|
|
89
|
+
* }
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export function file(options: Omit<FileFieldConfig, 'type'>): FileFieldConfig {
|
|
93
|
+
const fieldConfig: FileFieldConfig = {
|
|
94
|
+
type: 'file',
|
|
95
|
+
...options,
|
|
96
|
+
|
|
97
|
+
hooks: {
|
|
98
|
+
resolveInput: async ({ inputValue, context, item, fieldName }) => {
|
|
99
|
+
// If null/undefined, return as-is (deletion or no change)
|
|
100
|
+
if (inputValue === null || inputValue === undefined) {
|
|
101
|
+
return inputValue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// If already FileMetadata, keep existing (edit mode - no new file uploaded)
|
|
105
|
+
if (typeof inputValue === 'object' && 'filename' in inputValue && 'url' in inputValue) {
|
|
106
|
+
return inputValue as FileMetadata
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// If File object, upload it
|
|
110
|
+
// Check if it's a File-like object (has arrayBuffer method)
|
|
111
|
+
if (
|
|
112
|
+
typeof inputValue === 'object' &&
|
|
113
|
+
'arrayBuffer' in inputValue &&
|
|
114
|
+
typeof (inputValue as { arrayBuffer?: unknown }).arrayBuffer === 'function'
|
|
115
|
+
) {
|
|
116
|
+
// Convert File to buffer
|
|
117
|
+
const fileObj = inputValue as File
|
|
118
|
+
const arrayBuffer = await fileObj.arrayBuffer()
|
|
119
|
+
const buffer = Buffer.from(arrayBuffer)
|
|
120
|
+
|
|
121
|
+
// Upload file using context.storage utilities
|
|
122
|
+
const metadata = (await context.storage.uploadFile(fieldConfig.storage, fileObj, buffer, {
|
|
123
|
+
validation: fieldConfig.validation,
|
|
124
|
+
})) as FileMetadata
|
|
125
|
+
|
|
126
|
+
// If cleanupOnReplace is enabled and there was an old file, delete it
|
|
127
|
+
if (fieldConfig.cleanupOnReplace && item && fieldName) {
|
|
128
|
+
const oldMetadata = item[fieldName] as FileMetadata | null
|
|
129
|
+
if (oldMetadata && oldMetadata.filename) {
|
|
130
|
+
try {
|
|
131
|
+
await context.storage.deleteFile(oldMetadata.storageProvider, oldMetadata.filename)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
// Log error but don't fail the operation
|
|
134
|
+
console.error(`Failed to cleanup old file: ${oldMetadata.filename}`, error)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return metadata
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Unknown type - return as-is and let validation catch it
|
|
143
|
+
return inputValue
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
afterOperation: async ({ operation, item, fieldName, context }) => {
|
|
147
|
+
// Only cleanup on delete if enabled
|
|
148
|
+
if (operation === 'delete' && fieldConfig.cleanupOnDelete) {
|
|
149
|
+
const fileMetadata = item[fieldName] as FileMetadata | null
|
|
150
|
+
|
|
151
|
+
if (fileMetadata && fileMetadata.filename) {
|
|
152
|
+
try {
|
|
153
|
+
await context.storage.deleteFile(fileMetadata.storageProvider, fileMetadata.filename)
|
|
154
|
+
} catch (error) {
|
|
155
|
+
// Log error but don't fail the operation
|
|
156
|
+
console.error(`Failed to cleanup file on delete: ${fileMetadata.filename}`, error)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
getZodSchema: (_fieldName: string, _operation: 'create' | 'update') => {
|
|
164
|
+
// File metadata follows the FileMetadata schema
|
|
165
|
+
const fileMetadataSchema = z.object({
|
|
166
|
+
filename: z.string(),
|
|
167
|
+
originalFilename: z.string(),
|
|
168
|
+
url: z.string(), // Accept both absolute URLs and relative paths
|
|
169
|
+
mimeType: z.string(),
|
|
170
|
+
size: z.number(),
|
|
171
|
+
uploadedAt: z.string(),
|
|
172
|
+
storageProvider: z.string(),
|
|
173
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// Allow null or undefined values
|
|
177
|
+
return z.union([fileMetadataSchema, z.null(), z.undefined()])
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
getPrismaType: (_fieldName: string) => {
|
|
181
|
+
// Store as JSON in database
|
|
182
|
+
return { type: 'Json', modifiers: '?' }
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
getTypeScriptType: () => {
|
|
186
|
+
// TypeScript type is FileMetadata | null
|
|
187
|
+
return {
|
|
188
|
+
type: 'import("@opensaas/stack-storage").FileMetadata | null',
|
|
189
|
+
optional: true,
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return fieldConfig
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Creates an image upload field with optional transformations
|
|
199
|
+
*
|
|
200
|
+
* Uses JSON field backing to store image metadata including dimensions, transformations, etc.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```typescript
|
|
204
|
+
* fields: {
|
|
205
|
+
* avatar: image({
|
|
206
|
+
* storage: 'avatars',
|
|
207
|
+
* transformations: {
|
|
208
|
+
* thumbnail: { width: 100, height: 100, fit: 'cover' },
|
|
209
|
+
* profile: { width: 400, height: 400, fit: 'cover' }
|
|
210
|
+
* },
|
|
211
|
+
* validation: {
|
|
212
|
+
* maxFileSize: 5 * 1024 * 1024, // 5MB
|
|
213
|
+
* acceptedMimeTypes: ['image/jpeg', 'image/png', 'image/webp']
|
|
214
|
+
* }
|
|
215
|
+
* })
|
|
216
|
+
* }
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
export function image(options: Omit<ImageFieldConfig, 'type'>): ImageFieldConfig {
|
|
220
|
+
const fieldConfig: ImageFieldConfig = {
|
|
221
|
+
type: 'image',
|
|
222
|
+
...options,
|
|
223
|
+
|
|
224
|
+
hooks: {
|
|
225
|
+
resolveInput: async ({ inputValue, context, item, fieldName }) => {
|
|
226
|
+
// If null/undefined, return as-is (deletion or no change)
|
|
227
|
+
if (inputValue === null || inputValue === undefined) {
|
|
228
|
+
return inputValue
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// If already ImageMetadata, keep existing (edit mode - no new file uploaded)
|
|
232
|
+
if (
|
|
233
|
+
typeof inputValue === 'object' &&
|
|
234
|
+
'filename' in inputValue &&
|
|
235
|
+
'url' in inputValue &&
|
|
236
|
+
'width' in inputValue &&
|
|
237
|
+
'height' in inputValue
|
|
238
|
+
) {
|
|
239
|
+
return inputValue as ImageMetadata
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// If File object, upload it
|
|
243
|
+
// Check if it's a File-like object (has arrayBuffer method)
|
|
244
|
+
if (
|
|
245
|
+
typeof inputValue === 'object' &&
|
|
246
|
+
'arrayBuffer' in inputValue &&
|
|
247
|
+
typeof (inputValue as { arrayBuffer?: unknown }).arrayBuffer === 'function'
|
|
248
|
+
) {
|
|
249
|
+
// Convert File to buffer
|
|
250
|
+
const fileObj = inputValue as File
|
|
251
|
+
const arrayBuffer = await fileObj.arrayBuffer()
|
|
252
|
+
const buffer = Buffer.from(arrayBuffer)
|
|
253
|
+
|
|
254
|
+
// Upload image using context.storage utilities
|
|
255
|
+
const metadata = (await context.storage.uploadImage(
|
|
256
|
+
fieldConfig.storage,
|
|
257
|
+
fileObj,
|
|
258
|
+
buffer,
|
|
259
|
+
{
|
|
260
|
+
validation: fieldConfig.validation,
|
|
261
|
+
transformations: fieldConfig.transformations,
|
|
262
|
+
},
|
|
263
|
+
)) as ImageMetadata
|
|
264
|
+
|
|
265
|
+
// If cleanupOnReplace is enabled and there was an old file, delete it
|
|
266
|
+
if (fieldConfig.cleanupOnReplace && item && fieldName) {
|
|
267
|
+
const oldMetadata = item[fieldName] as ImageMetadata | null
|
|
268
|
+
if (oldMetadata && oldMetadata.filename) {
|
|
269
|
+
try {
|
|
270
|
+
await context.storage.deleteImage(oldMetadata)
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// Log error but don't fail the operation
|
|
273
|
+
console.error(`Failed to cleanup old image: ${oldMetadata.filename}`, error)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return metadata
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Unknown type - return as-is and let validation catch it
|
|
282
|
+
return inputValue
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
afterOperation: async ({ operation, item, fieldName, context }) => {
|
|
286
|
+
// Only cleanup on delete if enabled
|
|
287
|
+
if (operation === 'delete' && fieldConfig.cleanupOnDelete) {
|
|
288
|
+
const imageMetadata = item[fieldName] as ImageMetadata | null
|
|
289
|
+
|
|
290
|
+
if (imageMetadata && imageMetadata.filename) {
|
|
291
|
+
try {
|
|
292
|
+
await context.storage.deleteImage(imageMetadata)
|
|
293
|
+
} catch (error) {
|
|
294
|
+
// Log error but don't fail the operation
|
|
295
|
+
console.error(`Failed to cleanup image on delete: ${imageMetadata.filename}`, error)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
getZodSchema: (_fieldName: string, _operation: 'create' | 'update') => {
|
|
303
|
+
// Image metadata follows the ImageMetadata schema (extends FileMetadata)
|
|
304
|
+
const imageMetadataSchema = z.object({
|
|
305
|
+
filename: z.string(),
|
|
306
|
+
originalFilename: z.string(),
|
|
307
|
+
url: z.string(), // Accept both absolute URLs and relative paths
|
|
308
|
+
mimeType: z.string(),
|
|
309
|
+
size: z.number(),
|
|
310
|
+
width: z.number(),
|
|
311
|
+
height: z.number(),
|
|
312
|
+
uploadedAt: z.string(),
|
|
313
|
+
storageProvider: z.string(),
|
|
314
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
315
|
+
transformations: z
|
|
316
|
+
.record(
|
|
317
|
+
z.string(),
|
|
318
|
+
z.object({
|
|
319
|
+
url: z.string(), // Accept both absolute URLs and relative paths
|
|
320
|
+
width: z.number(),
|
|
321
|
+
height: z.number(),
|
|
322
|
+
size: z.number(),
|
|
323
|
+
}),
|
|
324
|
+
)
|
|
325
|
+
.optional(),
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Allow null or undefined values
|
|
329
|
+
return z.union([imageMetadataSchema, z.null(), z.undefined()])
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
getPrismaType: (_fieldName: string) => {
|
|
333
|
+
// Store as JSON in database
|
|
334
|
+
return { type: 'Json', modifiers: '?' }
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
getTypeScriptType: () => {
|
|
338
|
+
// TypeScript type is ImageMetadata | null
|
|
339
|
+
return {
|
|
340
|
+
type: 'import("@opensaas/stack-storage").ImageMetadata | null',
|
|
341
|
+
optional: true,
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return fieldConfig
|
|
347
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Config and types
|
|
2
|
+
export * from './config/index.js'
|
|
3
|
+
|
|
4
|
+
// Field builders
|
|
5
|
+
export * from './fields/index.js'
|
|
6
|
+
|
|
7
|
+
// Storage providers
|
|
8
|
+
export * from './providers/index.js'
|
|
9
|
+
|
|
10
|
+
// Runtime utilities
|
|
11
|
+
export * from './runtime/index.js'
|
|
12
|
+
|
|
13
|
+
// Upload utilities
|
|
14
|
+
export * from './utils/index.js'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LocalStorageProvider } from './local.js'
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { randomBytes } from 'node:crypto'
|
|
4
|
+
import type {
|
|
5
|
+
StorageProvider,
|
|
6
|
+
UploadOptions,
|
|
7
|
+
UploadResult,
|
|
8
|
+
LocalStorageConfig,
|
|
9
|
+
} from '../config/types.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Local filesystem storage provider
|
|
13
|
+
* Stores files on the local filesystem
|
|
14
|
+
*/
|
|
15
|
+
export class LocalStorageProvider implements StorageProvider {
|
|
16
|
+
private config: LocalStorageConfig
|
|
17
|
+
|
|
18
|
+
constructor(config: LocalStorageConfig) {
|
|
19
|
+
this.config = config
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ensures the upload directory exists
|
|
24
|
+
*/
|
|
25
|
+
private async ensureUploadDir(): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
await fs.access(this.config.uploadDir)
|
|
28
|
+
} catch {
|
|
29
|
+
await fs.mkdir(this.config.uploadDir, { recursive: true })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generates a unique filename if configured
|
|
35
|
+
*/
|
|
36
|
+
private generateFilename(originalFilename: string): string {
|
|
37
|
+
if (this.config.generateUniqueFilenames === false) {
|
|
38
|
+
return originalFilename
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ext = path.extname(originalFilename)
|
|
42
|
+
const uniqueId = randomBytes(16).toString('hex')
|
|
43
|
+
const timestamp = Date.now()
|
|
44
|
+
return `${timestamp}-${uniqueId}${ext}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async upload(
|
|
48
|
+
file: Buffer | Uint8Array,
|
|
49
|
+
filename: string,
|
|
50
|
+
options?: UploadOptions,
|
|
51
|
+
): Promise<UploadResult> {
|
|
52
|
+
await this.ensureUploadDir()
|
|
53
|
+
|
|
54
|
+
const generatedFilename = this.generateFilename(filename)
|
|
55
|
+
const filePath = path.join(this.config.uploadDir, generatedFilename)
|
|
56
|
+
|
|
57
|
+
// Write file to disk
|
|
58
|
+
await fs.writeFile(filePath, file)
|
|
59
|
+
|
|
60
|
+
// Get file stats for size
|
|
61
|
+
const stats = await fs.stat(filePath)
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
filename: generatedFilename,
|
|
65
|
+
url: `${this.config.serveUrl}/${generatedFilename}`,
|
|
66
|
+
size: stats.size,
|
|
67
|
+
contentType: options?.contentType || 'application/octet-stream',
|
|
68
|
+
metadata: options?.metadata,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async download(filename: string): Promise<Buffer> {
|
|
73
|
+
const filePath = path.join(this.config.uploadDir, filename)
|
|
74
|
+
return await fs.readFile(filePath)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async delete(filename: string): Promise<void> {
|
|
78
|
+
const filePath = path.join(this.config.uploadDir, filename)
|
|
79
|
+
await fs.unlink(filePath)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getUrl(filename: string): string {
|
|
83
|
+
return `${this.config.serveUrl}/${filename}`
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { OpenSaasConfig } from '@opensaas/stack-core'
|
|
2
|
+
import type {
|
|
3
|
+
StorageProvider,
|
|
4
|
+
LocalStorageConfig,
|
|
5
|
+
FileMetadata,
|
|
6
|
+
ImageMetadata,
|
|
7
|
+
ImageTransformationConfig,
|
|
8
|
+
} from '../config/types.js'
|
|
9
|
+
import { LocalStorageProvider } from '../providers/local.js'
|
|
10
|
+
import { validateFile, getMimeType, type FileValidationOptions } from '../utils/upload.js'
|
|
11
|
+
import { getImageDimensions, processImageTransformations } from '../utils/image.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a storage provider instance from config
|
|
15
|
+
*/
|
|
16
|
+
export function createStorageProvider(
|
|
17
|
+
config: OpenSaasConfig,
|
|
18
|
+
providerName: string,
|
|
19
|
+
): StorageProvider {
|
|
20
|
+
if (!config.storage || !config.storage[providerName]) {
|
|
21
|
+
throw new Error(`Storage provider '${providerName}' not found in config`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const providerConfig = config.storage[providerName]
|
|
25
|
+
|
|
26
|
+
switch (providerConfig.type) {
|
|
27
|
+
case 'local':
|
|
28
|
+
return new LocalStorageProvider(providerConfig as unknown as LocalStorageConfig)
|
|
29
|
+
default:
|
|
30
|
+
throw new Error(`Unknown storage provider type: ${providerConfig.type}`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Options for uploading a file
|
|
36
|
+
*/
|
|
37
|
+
export interface UploadFileOptions {
|
|
38
|
+
/** Validation options */
|
|
39
|
+
validation?: FileValidationOptions
|
|
40
|
+
/** Custom metadata */
|
|
41
|
+
metadata?: Record<string, string>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Options for uploading an image with transformations
|
|
46
|
+
*/
|
|
47
|
+
export interface UploadImageOptions extends UploadFileOptions {
|
|
48
|
+
/** Image transformations to apply */
|
|
49
|
+
transformations?: Record<string, ImageTransformationConfig>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Uploads a file to the specified storage provider
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* const metadata = await uploadFile(config, 'documents', {
|
|
58
|
+
* file,
|
|
59
|
+
* buffer,
|
|
60
|
+
* validation: {
|
|
61
|
+
* maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
62
|
+
* acceptedMimeTypes: ['application/pdf']
|
|
63
|
+
* }
|
|
64
|
+
* })
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export async function uploadFile(
|
|
68
|
+
config: OpenSaasConfig,
|
|
69
|
+
storageProviderName: string,
|
|
70
|
+
data: {
|
|
71
|
+
file: File
|
|
72
|
+
buffer: Buffer
|
|
73
|
+
},
|
|
74
|
+
options?: UploadFileOptions,
|
|
75
|
+
): Promise<FileMetadata> {
|
|
76
|
+
const { file, buffer } = data
|
|
77
|
+
|
|
78
|
+
// Validate file
|
|
79
|
+
if (options?.validation) {
|
|
80
|
+
const validation = validateFile(
|
|
81
|
+
{
|
|
82
|
+
size: file.size,
|
|
83
|
+
name: file.name,
|
|
84
|
+
type: file.type,
|
|
85
|
+
},
|
|
86
|
+
options.validation,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if (!validation.valid) {
|
|
90
|
+
throw new Error(validation.error)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Get storage provider
|
|
95
|
+
const provider = createStorageProvider(config, storageProviderName)
|
|
96
|
+
|
|
97
|
+
// Determine content type
|
|
98
|
+
const contentType = file.type || getMimeType(file.name)
|
|
99
|
+
|
|
100
|
+
// Upload file
|
|
101
|
+
const result = await provider.upload(buffer, file.name, {
|
|
102
|
+
contentType,
|
|
103
|
+
metadata: options?.metadata,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Return metadata
|
|
107
|
+
return {
|
|
108
|
+
filename: result.filename,
|
|
109
|
+
originalFilename: file.name,
|
|
110
|
+
url: result.url,
|
|
111
|
+
mimeType: contentType,
|
|
112
|
+
size: result.size,
|
|
113
|
+
uploadedAt: new Date().toISOString(),
|
|
114
|
+
storageProvider: storageProviderName,
|
|
115
|
+
metadata: result.metadata,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Uploads an image with optional transformations
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```typescript
|
|
124
|
+
* const metadata = await uploadImage(config, 'avatars', {
|
|
125
|
+
* file,
|
|
126
|
+
* buffer,
|
|
127
|
+
* validation: {
|
|
128
|
+
* maxFileSize: 5 * 1024 * 1024, // 5MB
|
|
129
|
+
* acceptedMimeTypes: ['image/jpeg', 'image/png', 'image/webp']
|
|
130
|
+
* },
|
|
131
|
+
* transformations: {
|
|
132
|
+
* thumbnail: { width: 100, height: 100, fit: 'cover' },
|
|
133
|
+
* profile: { width: 400, height: 400, fit: 'cover' }
|
|
134
|
+
* }
|
|
135
|
+
* })
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export async function uploadImage(
|
|
139
|
+
config: OpenSaasConfig,
|
|
140
|
+
storageProviderName: string,
|
|
141
|
+
data: {
|
|
142
|
+
file: File
|
|
143
|
+
buffer: Buffer
|
|
144
|
+
},
|
|
145
|
+
options?: UploadImageOptions,
|
|
146
|
+
): Promise<ImageMetadata> {
|
|
147
|
+
const { file, buffer } = data
|
|
148
|
+
|
|
149
|
+
// Validate file
|
|
150
|
+
if (options?.validation) {
|
|
151
|
+
const validation = validateFile(
|
|
152
|
+
{
|
|
153
|
+
size: file.size,
|
|
154
|
+
name: file.name,
|
|
155
|
+
type: file.type,
|
|
156
|
+
},
|
|
157
|
+
options.validation,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if (!validation.valid) {
|
|
161
|
+
throw new Error(validation.error)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Get storage provider
|
|
166
|
+
const provider = createStorageProvider(config, storageProviderName)
|
|
167
|
+
|
|
168
|
+
// Determine content type
|
|
169
|
+
const contentType = file.type || getMimeType(file.name)
|
|
170
|
+
|
|
171
|
+
// Get original image dimensions
|
|
172
|
+
const { width, height } = await getImageDimensions(buffer)
|
|
173
|
+
|
|
174
|
+
// Upload original image
|
|
175
|
+
const result = await provider.upload(buffer, file.name, {
|
|
176
|
+
contentType,
|
|
177
|
+
metadata: options?.metadata,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// Process transformations if provided
|
|
181
|
+
let transformations:
|
|
182
|
+
| Record<string, { url: string; width: number; height: number; size: number }>
|
|
183
|
+
| undefined
|
|
184
|
+
if (options?.transformations) {
|
|
185
|
+
transformations = await processImageTransformations(
|
|
186
|
+
buffer,
|
|
187
|
+
file.name,
|
|
188
|
+
options.transformations,
|
|
189
|
+
provider,
|
|
190
|
+
contentType,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Return metadata
|
|
195
|
+
return {
|
|
196
|
+
filename: result.filename,
|
|
197
|
+
originalFilename: file.name,
|
|
198
|
+
url: result.url,
|
|
199
|
+
mimeType: contentType,
|
|
200
|
+
size: result.size,
|
|
201
|
+
width,
|
|
202
|
+
height,
|
|
203
|
+
uploadedAt: new Date().toISOString(),
|
|
204
|
+
storageProvider: storageProviderName,
|
|
205
|
+
metadata: result.metadata,
|
|
206
|
+
transformations,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Deletes a file from storage
|
|
212
|
+
*/
|
|
213
|
+
export async function deleteFile(
|
|
214
|
+
config: OpenSaasConfig,
|
|
215
|
+
storageProviderName: string,
|
|
216
|
+
filename: string,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
const provider = createStorageProvider(config, storageProviderName)
|
|
219
|
+
await provider.delete(filename)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Deletes an image and all its transformations from storage
|
|
224
|
+
*/
|
|
225
|
+
export async function deleteImage(config: OpenSaasConfig, metadata: ImageMetadata): Promise<void> {
|
|
226
|
+
const provider = createStorageProvider(config, metadata.storageProvider)
|
|
227
|
+
|
|
228
|
+
// Delete original image
|
|
229
|
+
await provider.delete(metadata.filename)
|
|
230
|
+
|
|
231
|
+
// Delete all transformations
|
|
232
|
+
if (metadata.transformations) {
|
|
233
|
+
for (const transformationResult of Object.values(metadata.transformations)) {
|
|
234
|
+
// Extract filename from URL
|
|
235
|
+
const filename = transformationResult.url.split('/').pop()
|
|
236
|
+
if (filename) {
|
|
237
|
+
await provider.delete(filename)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export { parseFileFromFormData } from '../utils/upload.js'
|