@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,111 @@
|
|
|
1
|
+
import sharp from 'sharp'
|
|
2
|
+
import type {
|
|
3
|
+
ImageTransformationConfig,
|
|
4
|
+
ImageTransformationResult,
|
|
5
|
+
StorageProvider,
|
|
6
|
+
} from '../config/types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Gets image dimensions from a buffer
|
|
10
|
+
*/
|
|
11
|
+
export async function getImageDimensions(
|
|
12
|
+
buffer: Buffer | Uint8Array,
|
|
13
|
+
): Promise<{ width: number; height: number }> {
|
|
14
|
+
const metadata = await sharp(buffer).metadata()
|
|
15
|
+
return {
|
|
16
|
+
width: metadata.width || 0,
|
|
17
|
+
height: metadata.height || 0,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Applies a single transformation to an image
|
|
23
|
+
*/
|
|
24
|
+
export async function transformImage(
|
|
25
|
+
buffer: Buffer | Uint8Array,
|
|
26
|
+
transformation: ImageTransformationConfig,
|
|
27
|
+
): Promise<Buffer> {
|
|
28
|
+
let image = sharp(buffer)
|
|
29
|
+
|
|
30
|
+
// Apply resizing
|
|
31
|
+
if (transformation.width || transformation.height) {
|
|
32
|
+
image = image.resize({
|
|
33
|
+
width: transformation.width,
|
|
34
|
+
height: transformation.height,
|
|
35
|
+
fit: transformation.fit || 'cover',
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Apply format conversion
|
|
40
|
+
if (transformation.format) {
|
|
41
|
+
const options = {
|
|
42
|
+
quality: transformation.quality || 80,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
switch (transformation.format) {
|
|
46
|
+
case 'jpeg':
|
|
47
|
+
image = image.jpeg(options)
|
|
48
|
+
break
|
|
49
|
+
case 'png':
|
|
50
|
+
image = image.png(options)
|
|
51
|
+
break
|
|
52
|
+
case 'webp':
|
|
53
|
+
image = image.webp(options)
|
|
54
|
+
break
|
|
55
|
+
case 'avif':
|
|
56
|
+
image = image.avif(options)
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return await image.toBuffer()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Processes all transformations for an image
|
|
66
|
+
* Uploads the original and all transformed versions
|
|
67
|
+
*/
|
|
68
|
+
export async function processImageTransformations(
|
|
69
|
+
buffer: Buffer | Uint8Array,
|
|
70
|
+
originalFilename: string,
|
|
71
|
+
transformations: Record<string, ImageTransformationConfig>,
|
|
72
|
+
storageProvider: StorageProvider,
|
|
73
|
+
contentType: string,
|
|
74
|
+
): Promise<Record<string, ImageTransformationResult>> {
|
|
75
|
+
const results: Record<string, ImageTransformationResult> = {}
|
|
76
|
+
|
|
77
|
+
for (const [name, config] of Object.entries(transformations)) {
|
|
78
|
+
// Transform the image
|
|
79
|
+
const transformedBuffer = await transformImage(buffer, config)
|
|
80
|
+
|
|
81
|
+
// Get dimensions of transformed image
|
|
82
|
+
const { width, height } = await getImageDimensions(transformedBuffer)
|
|
83
|
+
|
|
84
|
+
// Generate filename for transformation
|
|
85
|
+
const ext = config.format ? `.${config.format}` : ''
|
|
86
|
+
const transformedFilename = `${originalFilename}-${name}${ext}`
|
|
87
|
+
|
|
88
|
+
// Upload transformed image
|
|
89
|
+
const uploadResult = await storageProvider.upload(transformedBuffer, transformedFilename, {
|
|
90
|
+
contentType:
|
|
91
|
+
config.format === 'jpeg'
|
|
92
|
+
? 'image/jpeg'
|
|
93
|
+
: config.format === 'png'
|
|
94
|
+
? 'image/png'
|
|
95
|
+
: config.format === 'webp'
|
|
96
|
+
? 'image/webp'
|
|
97
|
+
: config.format === 'avif'
|
|
98
|
+
? 'image/avif'
|
|
99
|
+
: contentType,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
results[name] = {
|
|
103
|
+
url: uploadResult.url,
|
|
104
|
+
width,
|
|
105
|
+
height,
|
|
106
|
+
size: uploadResult.size,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return results
|
|
111
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import mime from 'mime-types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* File validation options
|
|
5
|
+
*/
|
|
6
|
+
export interface FileValidationOptions {
|
|
7
|
+
/** Maximum file size in bytes */
|
|
8
|
+
maxFileSize?: number
|
|
9
|
+
/** Accepted MIME types (e.g., ['image/jpeg', 'image/png']) */
|
|
10
|
+
acceptedMimeTypes?: string[]
|
|
11
|
+
/** Accepted file extensions (e.g., ['.jpg', '.png']) */
|
|
12
|
+
acceptedExtensions?: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* File validation result
|
|
17
|
+
*/
|
|
18
|
+
export interface FileValidationResult {
|
|
19
|
+
valid: boolean
|
|
20
|
+
error?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validates a file against the provided options
|
|
25
|
+
*/
|
|
26
|
+
export function validateFile(
|
|
27
|
+
file: { size: number; name: string; type: string },
|
|
28
|
+
options?: FileValidationOptions,
|
|
29
|
+
): FileValidationResult {
|
|
30
|
+
if (!options) {
|
|
31
|
+
return { valid: true }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check file size
|
|
35
|
+
if (options.maxFileSize && file.size > options.maxFileSize) {
|
|
36
|
+
return {
|
|
37
|
+
valid: false,
|
|
38
|
+
error: `File size exceeds maximum allowed size of ${formatFileSize(options.maxFileSize)}`,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check MIME type
|
|
43
|
+
if (options.acceptedMimeTypes && options.acceptedMimeTypes.length > 0) {
|
|
44
|
+
const fileMimeType = file.type || mime.lookup(file.name) || ''
|
|
45
|
+
if (!options.acceptedMimeTypes.includes(fileMimeType)) {
|
|
46
|
+
return {
|
|
47
|
+
valid: false,
|
|
48
|
+
error: `File type '${fileMimeType}' is not allowed. Accepted types: ${options.acceptedMimeTypes.join(', ')}`,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check file extension
|
|
54
|
+
if (options.acceptedExtensions && options.acceptedExtensions.length > 0) {
|
|
55
|
+
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase()
|
|
56
|
+
if (!options.acceptedExtensions.includes(ext)) {
|
|
57
|
+
return {
|
|
58
|
+
valid: false,
|
|
59
|
+
error: `File extension '${ext}' is not allowed. Accepted extensions: ${options.acceptedExtensions.join(', ')}`,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { valid: true }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Formats file size in human-readable format
|
|
69
|
+
*/
|
|
70
|
+
export function formatFileSize(bytes: number): string {
|
|
71
|
+
if (bytes === 0) return '0 Bytes'
|
|
72
|
+
|
|
73
|
+
const k = 1024
|
|
74
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
75
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
76
|
+
|
|
77
|
+
return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Gets MIME type from filename
|
|
82
|
+
*/
|
|
83
|
+
export function getMimeType(filename: string): string {
|
|
84
|
+
return mime.lookup(filename) || 'application/octet-stream'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Extracts file metadata from a File or Blob
|
|
89
|
+
*/
|
|
90
|
+
export interface FileInfo {
|
|
91
|
+
name: string
|
|
92
|
+
size: number
|
|
93
|
+
type: string
|
|
94
|
+
lastModified?: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Converts a File/Blob to Buffer for Node.js processing
|
|
99
|
+
*/
|
|
100
|
+
export async function fileToBuffer(file: Blob | File): Promise<Buffer> {
|
|
101
|
+
const arrayBuffer = await file.arrayBuffer()
|
|
102
|
+
return Buffer.from(arrayBuffer)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parses FormData and extracts file information
|
|
107
|
+
* This is a utility for developers to use in their upload routes
|
|
108
|
+
*/
|
|
109
|
+
export async function parseFileFromFormData(
|
|
110
|
+
formData: FormData,
|
|
111
|
+
fieldName: string = 'file',
|
|
112
|
+
): Promise<{ file: File; buffer: Buffer } | null> {
|
|
113
|
+
const file = formData.get(fieldName)
|
|
114
|
+
|
|
115
|
+
if (!file || !(file instanceof File)) {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const buffer = await fileToBuffer(file)
|
|
120
|
+
|
|
121
|
+
return { file, buffer }
|
|
122
|
+
}
|