@nebutra/uploads 0.1.0 → 0.1.2

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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * S3/R2 upload provider
3
+ * Supports both AWS S3 and Cloudflare R2 (S3-compatible)
4
+ */
5
+ import { randomUUID } from "node:crypto";
6
+ import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client, UploadPartCommand, } from "@aws-sdk/client-s3";
7
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
8
+ import { logger } from "@nebutra/logger";
9
+ export class S3UploadProvider {
10
+ client;
11
+ config;
12
+ uploadTracking;
13
+ constructor(config) {
14
+ this.uploadTracking = new Map();
15
+ this.config = {
16
+ accessKeyId: config.accessKeyId,
17
+ secretAccessKey: config.secretAccessKey,
18
+ region: config.region,
19
+ endpoint: config.endpoint,
20
+ publicUrl: config.publicUrl ?? "https://cdn.example.com",
21
+ forcePathStyle: config.forcePathStyle ?? false,
22
+ };
23
+ this.client = new S3Client({
24
+ region: this.config.region,
25
+ ...(this.config.endpoint !== undefined ? { endpoint: this.config.endpoint } : {}),
26
+ credentials: {
27
+ accessKeyId: this.config.accessKeyId,
28
+ secretAccessKey: this.config.secretAccessKey,
29
+ },
30
+ forcePathStyle: this.config.forcePathStyle,
31
+ });
32
+ logger.info("S3 upload provider initialized", { provider: "s3", region: this.config.region });
33
+ }
34
+ /**
35
+ * Build object key with tenant prefix
36
+ */
37
+ buildKey(target) {
38
+ const id = randomUUID();
39
+ if (target.tenantId) {
40
+ return `${target.tenantId}/${id}/${target.key.split("/").pop()}`;
41
+ }
42
+ return `${id}/${target.key.split("/").pop()}`;
43
+ }
44
+ /**
45
+ * Generate presigned PUT URL for simple uploads
46
+ */
47
+ async createPresignedUpload(target) {
48
+ const key = this.buildKey(target);
49
+ const expiresIn = 3600; // 1 hour
50
+ const command = new PutObjectCommand({
51
+ Bucket: target.bucket,
52
+ Key: key,
53
+ ContentType: target.contentType,
54
+ Metadata: target.metadata,
55
+ ...(target.acl !== undefined ? { ACL: target.acl } : {}),
56
+ });
57
+ const url = await getSignedUrl(this.client, command, { expiresIn });
58
+ logger.debug("Presigned upload URL generated", { key, bucket: target.bucket });
59
+ return {
60
+ url,
61
+ method: "PUT",
62
+ headers: {
63
+ "Content-Type": target.contentType,
64
+ },
65
+ expiresAt: new Date(Date.now() + expiresIn * 1000),
66
+ uploadId: key,
67
+ };
68
+ }
69
+ /**
70
+ * Initiate multipart upload for large files
71
+ */
72
+ async createMultipartUpload(target, partCount) {
73
+ const key = this.buildKey(target);
74
+ const createCmd = new CreateMultipartUploadCommand({
75
+ Bucket: target.bucket,
76
+ Key: key,
77
+ ContentType: target.contentType,
78
+ Metadata: target.metadata,
79
+ ...(target.acl !== undefined ? { ACL: target.acl } : {}),
80
+ });
81
+ const response = await this.client.send(createCmd);
82
+ if (!response.UploadId) {
83
+ throw new Error("Failed to initiate multipart upload: no UploadId returned");
84
+ }
85
+ // Track upload for completion
86
+ this.uploadTracking.set(response.UploadId, {
87
+ bucket: target.bucket,
88
+ key,
89
+ });
90
+ // Generate presigned URLs for each part
91
+ const uploadId = response.UploadId;
92
+ const parts = await Promise.all(Array.from({ length: partCount }, async (_, i) => {
93
+ const partNumber = i + 1;
94
+ const uploadPartCmd = new UploadPartCommand({
95
+ Bucket: target.bucket,
96
+ Key: key,
97
+ PartNumber: partNumber,
98
+ UploadId: uploadId,
99
+ });
100
+ const presignedUrl = await getSignedUrl(this.client, uploadPartCmd, {
101
+ expiresIn: 3600,
102
+ });
103
+ return {
104
+ partNumber,
105
+ presignedUrl,
106
+ };
107
+ }));
108
+ const completeUrl = await this.generateCompleteUrl(target.bucket, key, uploadId);
109
+ logger.info("Multipart upload initiated", {
110
+ key,
111
+ bucket: target.bucket,
112
+ uploadId,
113
+ partCount,
114
+ });
115
+ return {
116
+ uploadId,
117
+ key,
118
+ bucket: target.bucket,
119
+ parts,
120
+ completeUrl,
121
+ };
122
+ }
123
+ /**
124
+ * Generate a presigned URL for the complete multipart upload operation
125
+ */
126
+ async generateCompleteUrl(bucket, key, uploadId) {
127
+ // This is a simplified implementation
128
+ // In production, you'd implement a callback endpoint on your server
129
+ return `https://api.example.com/uploads/complete?bucket=${bucket}&key=${encodeURIComponent(key)}&uploadId=${uploadId}`;
130
+ }
131
+ /**
132
+ * Complete a multipart upload
133
+ */
134
+ async completeMultipartUpload(uploadId, key, parts) {
135
+ const uploadInfo = this.uploadTracking.get(uploadId);
136
+ if (!uploadInfo) {
137
+ throw new Error(`Upload not found or already completed: ${uploadId}`);
138
+ }
139
+ const { bucket } = uploadInfo;
140
+ this.uploadTracking.delete(uploadId);
141
+ const completedParts = parts.map((part) => ({
142
+ ETag: part.etag,
143
+ PartNumber: part.partNumber,
144
+ }));
145
+ const command = new CompleteMultipartUploadCommand({
146
+ Bucket: bucket,
147
+ Key: key,
148
+ UploadId: uploadId,
149
+ MultipartUpload: {
150
+ Parts: completedParts,
151
+ },
152
+ });
153
+ const response = await this.client.send(command);
154
+ const url = this.generateDownloadUrl(key);
155
+ logger.info("Multipart upload completed", { key, bucket, uploadId, etag: response.ETag });
156
+ return {
157
+ key,
158
+ bucket,
159
+ url,
160
+ size: 0, // S3 doesn't provide size in complete response
161
+ contentType: "application/octet-stream",
162
+ ...(response.ETag !== undefined ? { etag: response.ETag } : {}),
163
+ };
164
+ }
165
+ /**
166
+ * Abort a multipart upload
167
+ */
168
+ async abortMultipartUpload(uploadId, key) {
169
+ const uploadInfo = this.uploadTracking.get(uploadId);
170
+ const bucket = uploadInfo?.bucket ?? "unknown";
171
+ const command = new AbortMultipartUploadCommand({
172
+ Bucket: bucket,
173
+ Key: key,
174
+ UploadId: uploadId,
175
+ });
176
+ await this.client.send(command);
177
+ this.uploadTracking.delete(uploadId);
178
+ logger.info("Multipart upload aborted", { key, bucket, uploadId });
179
+ }
180
+ /**
181
+ * Get Tus protocol endpoint configuration
182
+ */
183
+ async getTusEndpoint(target) {
184
+ const key = this.buildKey(target);
185
+ // Initiate multipart upload for Tus
186
+ const createCmd = new CreateMultipartUploadCommand({
187
+ Bucket: target.bucket,
188
+ Key: key,
189
+ ContentType: target.contentType,
190
+ });
191
+ const response = await this.client.send(createCmd);
192
+ if (!response.UploadId) {
193
+ throw new Error("Failed to create Tus upload session");
194
+ }
195
+ return {
196
+ endpoint: `${this.config.endpoint ?? "https://s3.amazonaws.com"}/${target.bucket}/${key}?uploadId=${response.UploadId}`,
197
+ chunkSize: 5 * 1024 * 1024, // 5MB chunks
198
+ retryDelays: [0, 1000, 3000, 5000],
199
+ metadata: {
200
+ ...target.metadata,
201
+ filename: target.key,
202
+ uploadId: response.UploadId,
203
+ },
204
+ };
205
+ }
206
+ /**
207
+ * Delete a file from S3
208
+ */
209
+ async deleteFile(bucket, key) {
210
+ const command = new DeleteObjectCommand({
211
+ Bucket: bucket,
212
+ Key: key,
213
+ });
214
+ await this.client.send(command);
215
+ logger.debug("File deleted from S3", { key, bucket });
216
+ }
217
+ /**
218
+ * Generate a presigned download URL
219
+ */
220
+ async getDownloadUrl(bucket, key, expiresInSec = 3600) {
221
+ const command = new GetObjectCommand({
222
+ Bucket: bucket,
223
+ Key: key,
224
+ });
225
+ return getSignedUrl(this.client, command, { expiresIn: expiresInSec });
226
+ }
227
+ /**
228
+ * Generate public CDN URL for assets
229
+ */
230
+ generateDownloadUrl(key) {
231
+ return `${this.config.publicUrl}/${key}`;
232
+ }
233
+ /**
234
+ * Clean up resources
235
+ */
236
+ async close() {
237
+ this.uploadTracking.clear();
238
+ this.client.destroy();
239
+ logger.debug("S3 upload provider closed");
240
+ }
241
+ }
242
+ /**
243
+ * Create S3 provider from environment variables
244
+ */
245
+ export function createS3Provider() {
246
+ const endpoint = process.env.S3_ENDPOINT;
247
+ const publicUrl = process.env.S3_PUBLIC_URL ?? process.env.R2_PUBLIC_URL;
248
+ const config = {
249
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? process.env.R2_ACCESS_KEY_ID ?? "",
250
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? process.env.R2_SECRET_ACCESS_KEY ?? "",
251
+ region: process.env.AWS_REGION ?? "auto",
252
+ ...(endpoint !== undefined ? { endpoint } : {}),
253
+ ...(publicUrl !== undefined ? { publicUrl } : {}),
254
+ forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
255
+ };
256
+ if (!config.accessKeyId || !config.secretAccessKey) {
257
+ throw new Error("AWS credentials not found. Set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY");
258
+ }
259
+ return new S3UploadProvider(config);
260
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Core types for large file uploads
3
+ */
4
+ export type UploadProviderType = "s3" | "blob" | "local";
5
+ export type UploadMethod = "PUT" | "POST";
6
+ export type ACLType = "private" | "public-read";
7
+ /**
8
+ * Target location and metadata for an upload
9
+ */
10
+ export interface UploadTarget {
11
+ bucket: string;
12
+ key: string;
13
+ contentType: string;
14
+ maxSize?: number;
15
+ metadata?: Record<string, string>;
16
+ tenantId?: string;
17
+ acl?: ACLType;
18
+ }
19
+ /**
20
+ * Presigned upload response for simple PUT uploads
21
+ */
22
+ export interface PresignedUpload {
23
+ url: string;
24
+ method: UploadMethod;
25
+ headers: Record<string, string>;
26
+ expiresAt: Date;
27
+ uploadId?: string;
28
+ }
29
+ /**
30
+ * Part configuration for multipart upload
31
+ */
32
+ export interface UploadPart {
33
+ partNumber: number;
34
+ presignedUrl: string;
35
+ }
36
+ /**
37
+ * Multipart upload response
38
+ */
39
+ export interface MultipartUpload {
40
+ uploadId: string;
41
+ key: string;
42
+ bucket: string;
43
+ parts: UploadPart[];
44
+ completeUrl: string;
45
+ }
46
+ /**
47
+ * Tus protocol upload configuration
48
+ */
49
+ export interface TusUploadConfig {
50
+ endpoint: string;
51
+ chunkSize: number;
52
+ retryDelays: number[];
53
+ metadata?: Record<string, string>;
54
+ }
55
+ /**
56
+ * Completed upload response
57
+ */
58
+ export interface UploadComplete {
59
+ key: string;
60
+ bucket: string;
61
+ url: string;
62
+ size: number;
63
+ contentType: string;
64
+ etag?: string;
65
+ tenantId?: string;
66
+ }
67
+ /**
68
+ * Part metadata for completing multipart upload
69
+ */
70
+ export interface CompletePart {
71
+ partNumber: number;
72
+ etag: string;
73
+ }
74
+ /**
75
+ * Upload provider interface
76
+ */
77
+ export interface UploadProvider {
78
+ /**
79
+ * Generate a presigned URL for direct client upload
80
+ */
81
+ createPresignedUpload(target: UploadTarget): Promise<PresignedUpload>;
82
+ /**
83
+ * Initiate a multipart upload and return part configurations
84
+ */
85
+ createMultipartUpload(target: UploadTarget, partCount: number): Promise<MultipartUpload>;
86
+ /**
87
+ * Complete a multipart upload after all parts are uploaded
88
+ */
89
+ completeMultipartUpload(uploadId: string, key: string, parts: CompletePart[]): Promise<UploadComplete>;
90
+ /**
91
+ * Abort/cancel a multipart upload
92
+ */
93
+ abortMultipartUpload(uploadId: string, key: string): Promise<void>;
94
+ /**
95
+ * Get Tus protocol endpoint configuration for resumable uploads
96
+ */
97
+ getTusEndpoint(target: UploadTarget): Promise<TusUploadConfig>;
98
+ /**
99
+ * Delete a file from storage
100
+ */
101
+ deleteFile(bucket: string, key: string): Promise<void>;
102
+ /**
103
+ * Generate a presigned download URL
104
+ */
105
+ getDownloadUrl(bucket: string, key: string, expiresInSec?: number): Promise<string>;
106
+ /**
107
+ * Clean up resources
108
+ */
109
+ close(): Promise<void>;
110
+ }
111
+ /**
112
+ * S3 provider configuration
113
+ */
114
+ export interface S3ProviderConfig {
115
+ accessKeyId: string;
116
+ secretAccessKey: string;
117
+ region: string;
118
+ endpoint?: string;
119
+ publicUrl?: string;
120
+ forcePathStyle?: boolean;
121
+ }
122
+ /**
123
+ * Local filesystem provider configuration
124
+ */
125
+ export interface LocalProviderConfig {
126
+ uploadDir: string;
127
+ httpBaseUrl: string;
128
+ maxConcurrency?: number;
129
+ }
130
+ /**
131
+ * Factory configuration for auto-detection
132
+ */
133
+ export interface ProviderConfig {
134
+ type?: UploadProviderType;
135
+ s3?: S3ProviderConfig;
136
+ local?: LocalProviderConfig;
137
+ }
138
+ /**
139
+ * Validation rules for uploads
140
+ */
141
+ export interface ValidationRules {
142
+ maxSize: number;
143
+ allowedTypes: string[];
144
+ blockedExtensions: string[];
145
+ imageDimensions?: {
146
+ maxWidth: number;
147
+ maxHeight: number;
148
+ };
149
+ }
150
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Core types for large file uploads
3
+ */
4
+ export {};
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Upload validation utilities
3
+ */
4
+ import type { ValidationRules } from "./types";
5
+ /**
6
+ * Validation error details
7
+ */
8
+ export interface ValidationError {
9
+ code: "size_exceeded" | "type_not_allowed" | "extension_blocked" | "invalid_image";
10
+ message: string;
11
+ details?: Record<string, unknown>;
12
+ }
13
+ /**
14
+ * Validate file before upload
15
+ */
16
+ export declare function validateUpload(filename: string, fileSize: number, mimeType: string, rules?: Partial<ValidationRules>): ValidationError | null;
17
+ /**
18
+ * Validate image dimensions
19
+ */
20
+ export declare function validateImageDimensions(dimensions: {
21
+ width: number;
22
+ height: number;
23
+ }, maxDimensions?: {
24
+ maxWidth: number;
25
+ maxHeight: number;
26
+ }): ValidationError | null;
27
+ /**
28
+ * Create validation rules for images
29
+ */
30
+ export declare function createImageValidationRules(options?: {
31
+ maxSize?: number;
32
+ maxWidth?: number;
33
+ maxHeight?: number;
34
+ }): ValidationRules;
35
+ /**
36
+ * Create validation rules for documents
37
+ */
38
+ export declare function createDocumentValidationRules(options?: {
39
+ maxSize?: number;
40
+ }): ValidationRules;
41
+ /**
42
+ * Log validation error
43
+ */
44
+ export declare function logValidationError(error: ValidationError, filename: string): void;
45
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAqC/C;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,eAAe,GAAG,kBAAkB,GAAG,mBAAmB,GAAG,eAAe,CAAC;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED;;GAEG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,KAAK,GAAE,OAAO,CAAC,eAAe,CAAM,GACnC,eAAe,GAAG,IAAI,CAgDxB;AAUD;;GAEG;AACH,wBAAgB,uBAAuB,CACrC,UAAU,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,EAC7C,aAAa,CAAC,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GACtD,eAAe,GAAG,IAAI,CA8BxB;AAED;;GAEG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,CAAC,EAAE;IACnD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GAAG,eAAe,CAUlB;AAED;;GAEG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,eAAe,CAc7F;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAMjF"}
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Upload validation utilities
3
+ */
4
+ import { logger } from "@nebutra/logger";
5
+ import { z } from "zod";
6
+ const DEFAULT_RULES = {
7
+ maxSize: 100 * 1024 * 1024, // 100MB default
8
+ allowedTypes: ["*/*"], // Allow all by default
9
+ blockedExtensions: [
10
+ // Executable files
11
+ ".exe",
12
+ ".bat",
13
+ ".cmd",
14
+ ".com",
15
+ ".pif",
16
+ ".scr",
17
+ ".vbs",
18
+ ".js", // Note: JavaScript can be dangerous depending on context
19
+ ".jar",
20
+ ".zip", // Often used to distribute executables
21
+ ".rar",
22
+ ".7z",
23
+ ".tar",
24
+ ".gz",
25
+ // Scripts
26
+ ".sh",
27
+ ".bash",
28
+ ".zsh",
29
+ ".ksh",
30
+ ".csh",
31
+ ".ps1",
32
+ ".psm1",
33
+ ".psd1",
34
+ ".msi",
35
+ ".dll",
36
+ ".so",
37
+ ".dylib",
38
+ ],
39
+ };
40
+ /**
41
+ * Validate file before upload
42
+ */
43
+ export function validateUpload(filename, fileSize, mimeType, rules = {}) {
44
+ const mergedRules = {
45
+ ...DEFAULT_RULES,
46
+ ...rules,
47
+ };
48
+ // Check file size
49
+ if (fileSize > mergedRules.maxSize) {
50
+ const maxSizeMB = Math.round(mergedRules.maxSize / (1024 * 1024));
51
+ const fileSizeMB = Math.round(fileSize / (1024 * 1024));
52
+ return {
53
+ code: "size_exceeded",
54
+ message: `File size (${fileSizeMB}MB) exceeds maximum allowed size (${maxSizeMB}MB)`,
55
+ details: { maxSize: mergedRules.maxSize, fileSize },
56
+ };
57
+ }
58
+ // Check MIME type
59
+ if (mergedRules.allowedTypes.length > 0 && !mergedRules.allowedTypes.includes("*/*")) {
60
+ const isAllowed = mergedRules.allowedTypes.some((type) => {
61
+ if (type.endsWith("/*")) {
62
+ const [category] = type.split("/");
63
+ const [fileMimeCategory] = mimeType.split("/");
64
+ return fileMimeCategory === category;
65
+ }
66
+ return type === mimeType;
67
+ });
68
+ if (!isAllowed) {
69
+ return {
70
+ code: "type_not_allowed",
71
+ message: `MIME type '${mimeType}' is not allowed. Allowed types: ${mergedRules.allowedTypes.join(", ")}`,
72
+ details: { mimeType, allowedTypes: mergedRules.allowedTypes },
73
+ };
74
+ }
75
+ }
76
+ // Check file extension
77
+ const ext = filename.toLowerCase().split(".").pop();
78
+ if (ext && mergedRules.blockedExtensions.includes(`.${ext}`)) {
79
+ return {
80
+ code: "extension_blocked",
81
+ message: `File extension '.${ext}' is not allowed`,
82
+ details: { extension: ext },
83
+ };
84
+ }
85
+ return null;
86
+ }
87
+ /**
88
+ * Image validation schema
89
+ */
90
+ const ImageDimensionsSchema = z.object({
91
+ width: z.number().int().positive(),
92
+ height: z.number().int().positive(),
93
+ });
94
+ /**
95
+ * Validate image dimensions
96
+ */
97
+ export function validateImageDimensions(dimensions, maxDimensions) {
98
+ try {
99
+ ImageDimensionsSchema.parse(dimensions);
100
+ }
101
+ catch (error) {
102
+ return {
103
+ code: "invalid_image",
104
+ message: "Invalid image dimensions",
105
+ details: { error: String(error) },
106
+ };
107
+ }
108
+ if (maxDimensions) {
109
+ if (dimensions.width > maxDimensions.maxWidth) {
110
+ return {
111
+ code: "invalid_image",
112
+ message: `Image width (${dimensions.width}px) exceeds maximum (${maxDimensions.maxWidth}px)`,
113
+ details: { width: dimensions.width, maxWidth: maxDimensions.maxWidth },
114
+ };
115
+ }
116
+ if (dimensions.height > maxDimensions.maxHeight) {
117
+ return {
118
+ code: "invalid_image",
119
+ message: `Image height (${dimensions.height}px) exceeds maximum (${maxDimensions.maxHeight}px)`,
120
+ details: { height: dimensions.height, maxHeight: maxDimensions.maxHeight },
121
+ };
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ /**
127
+ * Create validation rules for images
128
+ */
129
+ export function createImageValidationRules(options) {
130
+ return {
131
+ maxSize: options?.maxSize ?? 10 * 1024 * 1024, // 10MB default for images
132
+ allowedTypes: ["image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"],
133
+ blockedExtensions: DEFAULT_RULES.blockedExtensions,
134
+ imageDimensions: {
135
+ maxWidth: options?.maxWidth ?? 4096,
136
+ maxHeight: options?.maxHeight ?? 4096,
137
+ },
138
+ };
139
+ }
140
+ /**
141
+ * Create validation rules for documents
142
+ */
143
+ export function createDocumentValidationRules(options) {
144
+ return {
145
+ maxSize: options?.maxSize ?? 50 * 1024 * 1024, // 50MB default for documents
146
+ allowedTypes: [
147
+ "application/pdf",
148
+ "application/msword",
149
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
150
+ "application/vnd.ms-excel",
151
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
152
+ "text/plain",
153
+ "text/csv",
154
+ ],
155
+ blockedExtensions: DEFAULT_RULES.blockedExtensions,
156
+ };
157
+ }
158
+ /**
159
+ * Log validation error
160
+ */
161
+ export function logValidationError(error, filename) {
162
+ logger.warn(`Upload validation failed: ${error.message}`, {
163
+ code: error.code,
164
+ filename,
165
+ details: error.details,
166
+ });
167
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@nebutra/uploads",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
- "license": "AGPL-3.0",
5
+ "license": "MIT",
6
6
  "type": "module",
7
7
  "nebutra": {
8
8
  "status": "foundation",
@@ -43,7 +43,7 @@
43
43
  "@aws-sdk/client-s3": "^3.700.0",
44
44
  "@aws-sdk/s3-request-presigner": "^3.700.0",
45
45
  "zod": "^4.3.6",
46
- "@nebutra/logger": "0.1.0"
46
+ "@nebutra/logger": "0.1.2"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@vercel/blob": ">=0.23.0",