@nebutra/uploads 0.1.0 → 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/dist/factory.d.ts +21 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +129 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/providers/blob.d.ts +28 -0
- package/dist/providers/blob.d.ts.map +1 -0
- package/dist/providers/blob.js +88 -0
- package/dist/providers/local.d.ts +59 -0
- package/dist/providers/local.d.ts.map +1 -0
- package/dist/providers/local.js +201 -0
- package/dist/providers/s3.d.ts +60 -0
- package/dist/providers/s3.d.ts.map +1 -0
- package/dist/providers/s3.js +260 -0
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/validation.d.ts +45 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +167 -0
- package/package.json +3 -3
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload provider factory with auto-detection
|
|
3
|
+
*/
|
|
4
|
+
import type { ProviderConfig, UploadProvider, UploadProviderType } from "./types";
|
|
5
|
+
/**
|
|
6
|
+
* Create an upload provider with auto-detection
|
|
7
|
+
*/
|
|
8
|
+
export declare function createUploadProvider(config?: ProviderConfig): UploadProvider;
|
|
9
|
+
/**
|
|
10
|
+
* Get or create singleton instance
|
|
11
|
+
*/
|
|
12
|
+
export declare function getUploadProvider(config?: ProviderConfig): Promise<UploadProvider>;
|
|
13
|
+
/**
|
|
14
|
+
* Reset singleton instance (for testing)
|
|
15
|
+
*/
|
|
16
|
+
export declare function resetUploadProvider(): void;
|
|
17
|
+
/**
|
|
18
|
+
* Determine which provider is active
|
|
19
|
+
*/
|
|
20
|
+
export declare function getActiveProviderType(): UploadProviderType;
|
|
21
|
+
//# sourceMappingURL=factory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../src/factory.ts"],"names":[],"mappings":"AAAA;;GAEG;AAMH,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAIlF;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,cAAc,GAAG,cAAc,CAM5E;AAkFD;;GAEG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAOxF;AAED;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,IAAI,CAO1C;AAED;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,kBAAkB,CAyB1D"}
|
package/dist/factory.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload provider factory with auto-detection
|
|
3
|
+
*/
|
|
4
|
+
import { logger } from "@nebutra/logger";
|
|
5
|
+
import { createVercelBlobProvider, VercelBlobProvider } from "./providers/blob";
|
|
6
|
+
import { createLocalProvider, LocalUploadProvider } from "./providers/local";
|
|
7
|
+
import { createS3Provider, S3UploadProvider } from "./providers/s3";
|
|
8
|
+
let instance = null;
|
|
9
|
+
/**
|
|
10
|
+
* Create an upload provider with auto-detection
|
|
11
|
+
*/
|
|
12
|
+
export function createUploadProvider(config) {
|
|
13
|
+
if (config?.type) {
|
|
14
|
+
return createUploadProviderByType(config.type, config);
|
|
15
|
+
}
|
|
16
|
+
return autoDetectProvider(config);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create provider by explicit type
|
|
20
|
+
*/
|
|
21
|
+
function createUploadProviderByType(type, config) {
|
|
22
|
+
if (type === "s3") {
|
|
23
|
+
if (!config?.s3) {
|
|
24
|
+
return createS3Provider();
|
|
25
|
+
}
|
|
26
|
+
return new S3UploadProvider(config.s3);
|
|
27
|
+
}
|
|
28
|
+
if (type === "blob") {
|
|
29
|
+
return createVercelBlobProvider();
|
|
30
|
+
}
|
|
31
|
+
if (type === "local") {
|
|
32
|
+
if (!config?.local) {
|
|
33
|
+
return createLocalProvider();
|
|
34
|
+
}
|
|
35
|
+
return new LocalUploadProvider(config.local);
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`Unknown upload provider type: ${type}`);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Auto-detect provider based on environment
|
|
41
|
+
*/
|
|
42
|
+
function autoDetectProvider(config) {
|
|
43
|
+
// Priority 1: Check explicit UPLOAD_PROVIDER
|
|
44
|
+
const explicitProvider = process.env.UPLOAD_PROVIDER;
|
|
45
|
+
if (explicitProvider) {
|
|
46
|
+
logger.info("Using explicit upload provider", { provider: explicitProvider });
|
|
47
|
+
return createUploadProviderByType(explicitProvider, config);
|
|
48
|
+
}
|
|
49
|
+
// Priority 2: Check for Vercel Blob token (zero-config on Vercel)
|
|
50
|
+
if (process.env.BLOB_READ_WRITE_TOKEN) {
|
|
51
|
+
logger.info("Auto-detected Vercel Blob token, using Blob provider");
|
|
52
|
+
return createVercelBlobProvider();
|
|
53
|
+
}
|
|
54
|
+
// Priority 3: Check for R2 credentials
|
|
55
|
+
if (process.env.R2_ACCESS_KEY_ID || process.env.R2_SECRET_ACCESS_KEY) {
|
|
56
|
+
logger.info("Auto-detected R2 credentials, using S3 provider");
|
|
57
|
+
return new S3UploadProvider({
|
|
58
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID || "",
|
|
59
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || "",
|
|
60
|
+
region: "auto",
|
|
61
|
+
...(process.env.R2_ENDPOINT !== undefined ? { endpoint: process.env.R2_ENDPOINT } : {}),
|
|
62
|
+
...(process.env.R2_PUBLIC_URL !== undefined ? { publicUrl: process.env.R2_PUBLIC_URL } : {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// Priority 3: Check for AWS S3 credentials
|
|
66
|
+
if (process.env.AWS_ACCESS_KEY_ID || process.env.AWS_SECRET_ACCESS_KEY) {
|
|
67
|
+
logger.info("Auto-detected AWS credentials, using S3 provider");
|
|
68
|
+
return createS3Provider();
|
|
69
|
+
}
|
|
70
|
+
// Priority 4: Check for custom S3 endpoint (Minio, etc.)
|
|
71
|
+
if (process.env.S3_ENDPOINT) {
|
|
72
|
+
logger.info("Using custom S3 endpoint", { endpoint: process.env.S3_ENDPOINT });
|
|
73
|
+
return new S3UploadProvider({
|
|
74
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID || "",
|
|
75
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY || "",
|
|
76
|
+
region: process.env.AWS_REGION || "us-east-1",
|
|
77
|
+
...(process.env.S3_ENDPOINT !== undefined ? { endpoint: process.env.S3_ENDPOINT } : {}),
|
|
78
|
+
...(process.env.S3_PUBLIC_URL !== undefined ? { publicUrl: process.env.S3_PUBLIC_URL } : {}),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Fallback: Local filesystem
|
|
82
|
+
logger.info("No cloud credentials found, using local filesystem provider");
|
|
83
|
+
return createLocalProvider();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Get or create singleton instance
|
|
87
|
+
*/
|
|
88
|
+
export async function getUploadProvider(config) {
|
|
89
|
+
if (instance) {
|
|
90
|
+
return instance;
|
|
91
|
+
}
|
|
92
|
+
instance = createUploadProvider(config);
|
|
93
|
+
return instance;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Reset singleton instance (for testing)
|
|
97
|
+
*/
|
|
98
|
+
export function resetUploadProvider() {
|
|
99
|
+
if (instance) {
|
|
100
|
+
instance.close().catch((error) => {
|
|
101
|
+
logger.warn("Failed to close upload provider", { error });
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
instance = null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Determine which provider is active
|
|
108
|
+
*/
|
|
109
|
+
export function getActiveProviderType() {
|
|
110
|
+
if (instance instanceof S3UploadProvider) {
|
|
111
|
+
return "s3";
|
|
112
|
+
}
|
|
113
|
+
if (instance instanceof VercelBlobProvider) {
|
|
114
|
+
return "blob";
|
|
115
|
+
}
|
|
116
|
+
if (instance instanceof LocalUploadProvider) {
|
|
117
|
+
return "local";
|
|
118
|
+
}
|
|
119
|
+
if (process.env.UPLOAD_PROVIDER === "blob" || process.env.BLOB_READ_WRITE_TOKEN) {
|
|
120
|
+
return "blob";
|
|
121
|
+
}
|
|
122
|
+
if (process.env.UPLOAD_PROVIDER === "s3" ||
|
|
123
|
+
process.env.R2_ACCESS_KEY_ID ||
|
|
124
|
+
process.env.AWS_ACCESS_KEY_ID ||
|
|
125
|
+
process.env.S3_ENDPOINT) {
|
|
126
|
+
return "s3";
|
|
127
|
+
}
|
|
128
|
+
return "local";
|
|
129
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nebutra/uploads — Large file upload service
|
|
3
|
+
* Handles presigned, multipart, and Tus protocol uploads
|
|
4
|
+
*/
|
|
5
|
+
export { createUploadProvider, getActiveProviderType, getUploadProvider, resetUploadProvider, } from "./factory";
|
|
6
|
+
export { createVercelBlobProvider, VercelBlobProvider } from "./providers/blob";
|
|
7
|
+
export { createLocalProvider, LocalUploadProvider } from "./providers/local";
|
|
8
|
+
export { createS3Provider, S3UploadProvider } from "./providers/s3";
|
|
9
|
+
export type { ACLType, CompletePart, LocalProviderConfig, MultipartUpload, PresignedUpload, ProviderConfig, S3ProviderConfig, TusUploadConfig, UploadComplete, UploadMethod, UploadPart, UploadProvider, UploadProviderType, UploadTarget, ValidationRules, } from "./types";
|
|
10
|
+
export { createDocumentValidationRules, createImageValidationRules, logValidationError, type ValidationError, validateImageDimensions, validateUpload, } from "./validation";
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EACL,oBAAoB,EACpB,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,GACpB,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,wBAAwB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAChF,OAAO,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAC7E,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEpE,YAAY,EACV,OAAO,EACP,YAAY,EACZ,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,cAAc,EACd,YAAY,EACZ,UAAU,EACV,cAAc,EACd,kBAAkB,EAClB,YAAY,EACZ,eAAe,GAChB,MAAM,SAAS,CAAC;AAEjB,OAAO,EACL,6BAA6B,EAC7B,0BAA0B,EAC1B,kBAAkB,EAClB,KAAK,eAAe,EACpB,uBAAuB,EACvB,cAAc,GACf,MAAM,cAAc,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nebutra/uploads — Large file upload service
|
|
3
|
+
* Handles presigned, multipart, and Tus protocol uploads
|
|
4
|
+
*/
|
|
5
|
+
// Factory
|
|
6
|
+
export { createUploadProvider, getActiveProviderType, getUploadProvider, resetUploadProvider, } from "./factory";
|
|
7
|
+
// Providers
|
|
8
|
+
export { createVercelBlobProvider, VercelBlobProvider } from "./providers/blob";
|
|
9
|
+
export { createLocalProvider, LocalUploadProvider } from "./providers/local";
|
|
10
|
+
export { createS3Provider, S3UploadProvider } from "./providers/s3";
|
|
11
|
+
// Validation
|
|
12
|
+
export { createDocumentValidationRules, createImageValidationRules, logValidationError, validateImageDimensions, validateUpload, } from "./validation";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel Blob upload provider — zero-config file uploads on Vercel.
|
|
3
|
+
*
|
|
4
|
+
* Uses @vercel/blob for server-side uploads with public URLs.
|
|
5
|
+
* Requires BLOB_READ_WRITE_TOKEN env var (auto-provisioned on Vercel).
|
|
6
|
+
*
|
|
7
|
+
* Note: Vercel Blob doesn't support true multipart uploads or Tus protocol.
|
|
8
|
+
* For files > 500MB, use the S3 provider instead.
|
|
9
|
+
*/
|
|
10
|
+
import type { CompletePart, MultipartUpload, PresignedUpload, TusUploadConfig, UploadComplete, UploadProvider, UploadTarget } from "../types";
|
|
11
|
+
export interface VercelBlobConfig {
|
|
12
|
+
token?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class VercelBlobProvider implements UploadProvider {
|
|
15
|
+
private token;
|
|
16
|
+
constructor(config?: VercelBlobConfig);
|
|
17
|
+
private buildPathname;
|
|
18
|
+
createPresignedUpload(target: UploadTarget): Promise<PresignedUpload>;
|
|
19
|
+
createMultipartUpload(target: UploadTarget, _partCount: number): Promise<MultipartUpload>;
|
|
20
|
+
completeMultipartUpload(_uploadId: string, key: string, _parts: CompletePart[]): Promise<UploadComplete>;
|
|
21
|
+
abortMultipartUpload(_uploadId: string, key: string): Promise<void>;
|
|
22
|
+
getTusEndpoint(_target: UploadTarget): Promise<TusUploadConfig>;
|
|
23
|
+
deleteFile(_bucket: string, key: string): Promise<void>;
|
|
24
|
+
getDownloadUrl(_bucket: string, key: string, _expiresInSec?: number): Promise<string>;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare function createVercelBlobProvider(config?: VercelBlobConfig): VercelBlobProvider;
|
|
28
|
+
//# sourceMappingURL=blob.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blob.d.ts","sourceRoot":"","sources":["../../src/providers/blob.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EACV,YAAY,EACZ,eAAe,EACf,eAAe,EACf,eAAe,EACf,cAAc,EACd,cAAc,EACd,YAAY,EACb,MAAM,UAAU,CAAC;AAElB,MAAM,WAAW,gBAAgB;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,qBAAa,kBAAmB,YAAW,cAAc;IACvD,OAAO,CAAC,KAAK,CAAS;gBAEV,MAAM,GAAE,gBAAqB;IAIzC,OAAO,CAAC,aAAa;IAKf,qBAAqB,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC;IAyBrE,qBAAqB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAazF,uBAAuB,CAC3B,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,YAAY,EAAE,GACrB,OAAO,CAAC,cAAc,CAAC;IAWpB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKnE,cAAc,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC;IAM/D,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMvD,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAQrF,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B;AAED,wBAAgB,wBAAwB,CAAC,MAAM,CAAC,EAAE,gBAAgB,GAAG,kBAAkB,CAEtF"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel Blob upload provider — zero-config file uploads on Vercel.
|
|
3
|
+
*
|
|
4
|
+
* Uses @vercel/blob for server-side uploads with public URLs.
|
|
5
|
+
* Requires BLOB_READ_WRITE_TOKEN env var (auto-provisioned on Vercel).
|
|
6
|
+
*
|
|
7
|
+
* Note: Vercel Blob doesn't support true multipart uploads or Tus protocol.
|
|
8
|
+
* For files > 500MB, use the S3 provider instead.
|
|
9
|
+
*/
|
|
10
|
+
export class VercelBlobProvider {
|
|
11
|
+
token;
|
|
12
|
+
constructor(config = {}) {
|
|
13
|
+
this.token = config.token ?? process.env.BLOB_READ_WRITE_TOKEN ?? "";
|
|
14
|
+
}
|
|
15
|
+
buildPathname(target) {
|
|
16
|
+
const prefix = target.tenantId ? `${target.tenantId}/` : "";
|
|
17
|
+
return `${prefix}${target.key}`;
|
|
18
|
+
}
|
|
19
|
+
async createPresignedUpload(target) {
|
|
20
|
+
// Vercel Blob uses server-side put() — we generate a URL that the client
|
|
21
|
+
// can PUT to via the handleUpload client helper, or we return the blob URL
|
|
22
|
+
// after server-side upload.
|
|
23
|
+
const blob = await import("@vercel/blob");
|
|
24
|
+
const pathname = this.buildPathname(target);
|
|
25
|
+
const result = await blob.put(pathname, "", {
|
|
26
|
+
access: "public",
|
|
27
|
+
contentType: target.contentType,
|
|
28
|
+
token: this.token,
|
|
29
|
+
addRandomSuffix: true,
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
url: result.url,
|
|
33
|
+
method: "PUT",
|
|
34
|
+
headers: {
|
|
35
|
+
"Content-Type": target.contentType,
|
|
36
|
+
"x-vercel-blob-token": this.token,
|
|
37
|
+
},
|
|
38
|
+
expiresAt: new Date(Date.now() + 3600 * 1000), // 1 hour
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async createMultipartUpload(target, _partCount) {
|
|
42
|
+
// Vercel Blob handles large files internally — no client-side multipart needed
|
|
43
|
+
// We simulate the interface for compatibility
|
|
44
|
+
const presigned = await this.createPresignedUpload(target);
|
|
45
|
+
return {
|
|
46
|
+
uploadId: `blob_${Date.now()}`,
|
|
47
|
+
key: this.buildPathname(target),
|
|
48
|
+
bucket: target.bucket,
|
|
49
|
+
parts: [{ partNumber: 1, presignedUrl: presigned.url }],
|
|
50
|
+
completeUrl: presigned.url,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async completeMultipartUpload(_uploadId, key, _parts) {
|
|
54
|
+
// Vercel Blob uploads are atomic — no completion step needed
|
|
55
|
+
return {
|
|
56
|
+
key,
|
|
57
|
+
bucket: "vercel-blob",
|
|
58
|
+
url: `https://blob.vercel-storage.com/${key}`,
|
|
59
|
+
size: 0,
|
|
60
|
+
contentType: "application/octet-stream",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async abortMultipartUpload(_uploadId, key) {
|
|
64
|
+
// Clean up the blob if partially uploaded
|
|
65
|
+
await this.deleteFile("vercel-blob", key);
|
|
66
|
+
}
|
|
67
|
+
async getTusEndpoint(_target) {
|
|
68
|
+
throw new Error("Vercel Blob does not support Tus protocol. Use S3 provider for resumable uploads.");
|
|
69
|
+
}
|
|
70
|
+
async deleteFile(_bucket, key) {
|
|
71
|
+
const blob = await import("@vercel/blob");
|
|
72
|
+
const url = key.startsWith("http") ? key : `https://blob.vercel-storage.com/${key}`;
|
|
73
|
+
await blob.del(url, { token: this.token });
|
|
74
|
+
}
|
|
75
|
+
async getDownloadUrl(_bucket, key, _expiresInSec) {
|
|
76
|
+
// Vercel Blob URLs are public by default — no signed URL needed
|
|
77
|
+
const blob = await import("@vercel/blob");
|
|
78
|
+
const url = key.startsWith("http") ? key : `https://blob.vercel-storage.com/${key}`;
|
|
79
|
+
const meta = await blob.head(url, { token: this.token });
|
|
80
|
+
return meta.url;
|
|
81
|
+
}
|
|
82
|
+
async close() {
|
|
83
|
+
// No-op — Vercel Blob has no persistent connections
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function createVercelBlobProvider(config) {
|
|
87
|
+
return new VercelBlobProvider(config);
|
|
88
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local filesystem upload provider
|
|
3
|
+
* For development and testing
|
|
4
|
+
*/
|
|
5
|
+
import type { CompletePart, LocalProviderConfig, MultipartUpload, PresignedUpload, TusUploadConfig, UploadComplete, UploadProvider, UploadTarget } from "../types";
|
|
6
|
+
export declare class LocalUploadProvider implements UploadProvider {
|
|
7
|
+
private config;
|
|
8
|
+
private pendingUploads;
|
|
9
|
+
constructor(config: LocalProviderConfig);
|
|
10
|
+
/**
|
|
11
|
+
* Build object key with tenant prefix and UUID
|
|
12
|
+
*/
|
|
13
|
+
private buildKey;
|
|
14
|
+
/**
|
|
15
|
+
* Generate presigned URL for simple uploads
|
|
16
|
+
*/
|
|
17
|
+
createPresignedUpload(target: UploadTarget): Promise<PresignedUpload>;
|
|
18
|
+
/**
|
|
19
|
+
* Initiate multipart upload
|
|
20
|
+
*/
|
|
21
|
+
createMultipartUpload(target: UploadTarget, partCount: number): Promise<MultipartUpload>;
|
|
22
|
+
/**
|
|
23
|
+
* Complete a multipart upload
|
|
24
|
+
*/
|
|
25
|
+
completeMultipartUpload(uploadId: string, key: string, _parts: CompletePart[]): Promise<UploadComplete>;
|
|
26
|
+
/**
|
|
27
|
+
* Abort a multipart upload
|
|
28
|
+
*/
|
|
29
|
+
abortMultipartUpload(uploadId: string, _key: string): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Get Tus protocol endpoint configuration
|
|
32
|
+
*/
|
|
33
|
+
getTusEndpoint(target: UploadTarget): Promise<TusUploadConfig>;
|
|
34
|
+
/**
|
|
35
|
+
* Delete a file
|
|
36
|
+
*/
|
|
37
|
+
deleteFile(_bucket: string, key: string): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Generate a download URL
|
|
40
|
+
*/
|
|
41
|
+
getDownloadUrl(_bucket: string, key: string, _expiresInSec?: number): Promise<string>;
|
|
42
|
+
/**
|
|
43
|
+
* Ensure directory exists
|
|
44
|
+
*/
|
|
45
|
+
private ensureDir;
|
|
46
|
+
/**
|
|
47
|
+
* Generate a simple ETag from content
|
|
48
|
+
*/
|
|
49
|
+
private generateETag;
|
|
50
|
+
/**
|
|
51
|
+
* Clean up resources
|
|
52
|
+
*/
|
|
53
|
+
close(): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Create local provider from environment variables
|
|
57
|
+
*/
|
|
58
|
+
export declare function createLocalProvider(): LocalUploadProvider;
|
|
59
|
+
//# sourceMappingURL=local.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local.d.ts","sourceRoot":"","sources":["../../src/providers/local.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAMH,OAAO,KAAK,EACV,YAAY,EACZ,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,eAAe,EACf,cAAc,EACd,cAAc,EACd,YAAY,EACb,MAAM,UAAU,CAAC;AAElB,qBAAa,mBAAoB,YAAW,cAAc;IACxD,OAAO,CAAC,MAAM,CAAgC;IAC9C,OAAO,CAAC,cAAc,CAA2D;gBAErE,MAAM,EAAE,mBAAmB;IAcvC;;OAEG;IACH,OAAO,CAAC,QAAQ;IAQhB;;OAEG;IACG,qBAAqB,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC;IAqB3E;;OAEG;IACG,qBAAqB,CAAC,MAAM,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAmC9F;;OAEG;IACG,uBAAuB,CAC3B,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,YAAY,EAAE,GACrB,OAAO,CAAC,cAAc,CAAC;IAsC1B;;OAEG;IACG,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMzE;;OAEG;IACG,cAAc,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC;IAcpE;;OAEG;IACG,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7D;;OAEG;IACG,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI3F;;OAEG;YACW,SAAS;IAUvB;;OAEG;IACH,OAAO,CAAC,YAAY;IAUpB;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAI7B;AAED;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,mBAAmB,CAUzD"}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local filesystem upload provider
|
|
3
|
+
* For development and testing
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { logger } from "@nebutra/logger";
|
|
9
|
+
export class LocalUploadProvider {
|
|
10
|
+
config;
|
|
11
|
+
pendingUploads;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.config = {
|
|
14
|
+
uploadDir: config.uploadDir,
|
|
15
|
+
httpBaseUrl: config.httpBaseUrl,
|
|
16
|
+
maxConcurrency: config.maxConcurrency || 10,
|
|
17
|
+
};
|
|
18
|
+
this.pendingUploads = new Map();
|
|
19
|
+
logger.info("Local upload provider initialized", {
|
|
20
|
+
uploadDir: this.config.uploadDir,
|
|
21
|
+
httpBaseUrl: this.config.httpBaseUrl,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build object key with tenant prefix and UUID
|
|
26
|
+
*/
|
|
27
|
+
buildKey(target) {
|
|
28
|
+
const id = randomUUID();
|
|
29
|
+
if (target.tenantId) {
|
|
30
|
+
return `${target.tenantId}/${id}/${target.key.split("/").pop()}`;
|
|
31
|
+
}
|
|
32
|
+
return `${id}/${target.key.split("/").pop()}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Generate presigned URL for simple uploads
|
|
36
|
+
*/
|
|
37
|
+
async createPresignedUpload(target) {
|
|
38
|
+
const key = this.buildKey(target);
|
|
39
|
+
// Ensure directory exists
|
|
40
|
+
await this.ensureDir(target.bucket);
|
|
41
|
+
const expiresIn = 3600; // 1 hour
|
|
42
|
+
logger.debug("Local presigned upload URL generated", { key, bucket: target.bucket });
|
|
43
|
+
return {
|
|
44
|
+
url: `${this.config.httpBaseUrl}/upload?bucket=${encodeURIComponent(target.bucket)}&key=${encodeURIComponent(key)}`,
|
|
45
|
+
method: "PUT",
|
|
46
|
+
headers: {
|
|
47
|
+
"Content-Type": target.contentType,
|
|
48
|
+
},
|
|
49
|
+
expiresAt: new Date(Date.now() + expiresIn * 1000),
|
|
50
|
+
uploadId: key,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Initiate multipart upload
|
|
55
|
+
*/
|
|
56
|
+
async createMultipartUpload(target, partCount) {
|
|
57
|
+
const key = this.buildKey(target);
|
|
58
|
+
const uploadId = randomUUID();
|
|
59
|
+
// Ensure directory exists
|
|
60
|
+
await this.ensureDir(target.bucket);
|
|
61
|
+
// Initialize upload tracking
|
|
62
|
+
this.pendingUploads.set(uploadId, {
|
|
63
|
+
key,
|
|
64
|
+
parts: new Map(),
|
|
65
|
+
});
|
|
66
|
+
// Generate presigned URLs for each part
|
|
67
|
+
const parts = Array.from({ length: partCount }, (_, i) => ({
|
|
68
|
+
partNumber: i + 1,
|
|
69
|
+
presignedUrl: `${this.config.httpBaseUrl}/upload/multipart?bucket=${encodeURIComponent(target.bucket)}&uploadId=${encodeURIComponent(uploadId)}&partNumber=${i + 1}`,
|
|
70
|
+
}));
|
|
71
|
+
logger.info("Local multipart upload initiated", {
|
|
72
|
+
key,
|
|
73
|
+
bucket: target.bucket,
|
|
74
|
+
uploadId,
|
|
75
|
+
partCount,
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
uploadId,
|
|
79
|
+
key,
|
|
80
|
+
bucket: target.bucket,
|
|
81
|
+
parts,
|
|
82
|
+
completeUrl: `${this.config.httpBaseUrl}/upload/multipart/complete?bucket=${encodeURIComponent(target.bucket)}&uploadId=${encodeURIComponent(uploadId)}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Complete a multipart upload
|
|
87
|
+
*/
|
|
88
|
+
async completeMultipartUpload(uploadId, key, _parts) {
|
|
89
|
+
const upload = this.pendingUploads.get(uploadId);
|
|
90
|
+
if (!upload) {
|
|
91
|
+
throw new Error(`Upload not found: ${uploadId}`);
|
|
92
|
+
}
|
|
93
|
+
// For local storage, etag is a simple hash of part data
|
|
94
|
+
// In production, this would be the actual S3 etag
|
|
95
|
+
const filePath = join(this.config.uploadDir, key);
|
|
96
|
+
// Ensure directory exists
|
|
97
|
+
const dir = filePath.substring(0, filePath.lastIndexOf("/"));
|
|
98
|
+
await this.ensureDir(dir);
|
|
99
|
+
// Combine parts in order
|
|
100
|
+
const parts_array = Array.from(upload.parts.entries())
|
|
101
|
+
.sort(([a], [b]) => a - b)
|
|
102
|
+
.map(([, buffer]) => buffer);
|
|
103
|
+
const combined = Buffer.concat(parts_array);
|
|
104
|
+
await writeFile(filePath, combined);
|
|
105
|
+
this.pendingUploads.delete(uploadId);
|
|
106
|
+
logger.info("Local multipart upload completed", { key, uploadId, size: combined.length });
|
|
107
|
+
return {
|
|
108
|
+
key,
|
|
109
|
+
bucket: "local",
|
|
110
|
+
url: `${this.config.httpBaseUrl}/files/${key}`,
|
|
111
|
+
size: combined.length,
|
|
112
|
+
contentType: "application/octet-stream",
|
|
113
|
+
etag: this.generateETag(combined),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Abort a multipart upload
|
|
118
|
+
*/
|
|
119
|
+
async abortMultipartUpload(uploadId, _key) {
|
|
120
|
+
this.pendingUploads.delete(uploadId);
|
|
121
|
+
logger.info("Local multipart upload aborted", { uploadId });
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Get Tus protocol endpoint configuration
|
|
125
|
+
*/
|
|
126
|
+
async getTusEndpoint(target) {
|
|
127
|
+
await this.ensureDir(target.bucket);
|
|
128
|
+
return {
|
|
129
|
+
endpoint: `${this.config.httpBaseUrl}/upload/tus`,
|
|
130
|
+
chunkSize: 1 * 1024 * 1024, // 1MB chunks for local testing
|
|
131
|
+
retryDelays: [0, 1000, 3000],
|
|
132
|
+
metadata: {
|
|
133
|
+
...target.metadata,
|
|
134
|
+
filename: target.key,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Delete a file
|
|
140
|
+
*/
|
|
141
|
+
async deleteFile(_bucket, key) {
|
|
142
|
+
const filePath = join(this.config.uploadDir, key);
|
|
143
|
+
try {
|
|
144
|
+
await rm(filePath, { force: true });
|
|
145
|
+
logger.debug("Local file deleted", { key });
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
logger.warn("Failed to delete local file", { key, error });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Generate a download URL
|
|
153
|
+
*/
|
|
154
|
+
async getDownloadUrl(_bucket, key, _expiresInSec) {
|
|
155
|
+
return `${this.config.httpBaseUrl}/files/${key}`;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Ensure directory exists
|
|
159
|
+
*/
|
|
160
|
+
async ensureDir(dirPath) {
|
|
161
|
+
const fullPath = join(this.config.uploadDir, dirPath);
|
|
162
|
+
try {
|
|
163
|
+
await mkdir(fullPath, { recursive: true });
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
logger.warn("Failed to create directory", { path: fullPath, error });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Generate a simple ETag from content
|
|
171
|
+
*/
|
|
172
|
+
generateETag(buffer) {
|
|
173
|
+
// Simple hash implementation
|
|
174
|
+
let hash = 0;
|
|
175
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
176
|
+
hash = (hash << 5) - hash + (buffer[i] ?? 0);
|
|
177
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
178
|
+
}
|
|
179
|
+
return `"${Math.abs(hash).toString(16)}"`;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Clean up resources
|
|
183
|
+
*/
|
|
184
|
+
async close() {
|
|
185
|
+
this.pendingUploads.clear();
|
|
186
|
+
logger.debug("Local upload provider closed");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Create local provider from environment variables
|
|
191
|
+
*/
|
|
192
|
+
export function createLocalProvider() {
|
|
193
|
+
const config = {
|
|
194
|
+
uploadDir: process.env.UPLOAD_DIR || "/tmp/nebutra-uploads",
|
|
195
|
+
httpBaseUrl: process.env.UPLOAD_HTTP_BASE_URL || "http://localhost:3000",
|
|
196
|
+
maxConcurrency: process.env.UPLOAD_MAX_CONCURRENCY
|
|
197
|
+
? parseInt(process.env.UPLOAD_MAX_CONCURRENCY, 10)
|
|
198
|
+
: 10,
|
|
199
|
+
};
|
|
200
|
+
return new LocalUploadProvider(config);
|
|
201
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3/R2 upload provider
|
|
3
|
+
* Supports both AWS S3 and Cloudflare R2 (S3-compatible)
|
|
4
|
+
*/
|
|
5
|
+
import type { CompletePart, MultipartUpload, PresignedUpload, S3ProviderConfig, TusUploadConfig, UploadComplete, UploadProvider, UploadTarget } from "../types";
|
|
6
|
+
export declare class S3UploadProvider implements UploadProvider {
|
|
7
|
+
private client;
|
|
8
|
+
private config;
|
|
9
|
+
private uploadTracking;
|
|
10
|
+
constructor(config: S3ProviderConfig);
|
|
11
|
+
/**
|
|
12
|
+
* Build object key with tenant prefix
|
|
13
|
+
*/
|
|
14
|
+
private buildKey;
|
|
15
|
+
/**
|
|
16
|
+
* Generate presigned PUT URL for simple uploads
|
|
17
|
+
*/
|
|
18
|
+
createPresignedUpload(target: UploadTarget): Promise<PresignedUpload>;
|
|
19
|
+
/**
|
|
20
|
+
* Initiate multipart upload for large files
|
|
21
|
+
*/
|
|
22
|
+
createMultipartUpload(target: UploadTarget, partCount: number): Promise<MultipartUpload>;
|
|
23
|
+
/**
|
|
24
|
+
* Generate a presigned URL for the complete multipart upload operation
|
|
25
|
+
*/
|
|
26
|
+
private generateCompleteUrl;
|
|
27
|
+
/**
|
|
28
|
+
* Complete a multipart upload
|
|
29
|
+
*/
|
|
30
|
+
completeMultipartUpload(uploadId: string, key: string, parts: CompletePart[]): Promise<UploadComplete>;
|
|
31
|
+
/**
|
|
32
|
+
* Abort a multipart upload
|
|
33
|
+
*/
|
|
34
|
+
abortMultipartUpload(uploadId: string, key: string): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Get Tus protocol endpoint configuration
|
|
37
|
+
*/
|
|
38
|
+
getTusEndpoint(target: UploadTarget): Promise<TusUploadConfig>;
|
|
39
|
+
/**
|
|
40
|
+
* Delete a file from S3
|
|
41
|
+
*/
|
|
42
|
+
deleteFile(bucket: string, key: string): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Generate a presigned download URL
|
|
45
|
+
*/
|
|
46
|
+
getDownloadUrl(bucket: string, key: string, expiresInSec?: number): Promise<string>;
|
|
47
|
+
/**
|
|
48
|
+
* Generate public CDN URL for assets
|
|
49
|
+
*/
|
|
50
|
+
private generateDownloadUrl;
|
|
51
|
+
/**
|
|
52
|
+
* Clean up resources
|
|
53
|
+
*/
|
|
54
|
+
close(): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Create S3 provider from environment variables
|
|
58
|
+
*/
|
|
59
|
+
export declare function createS3Provider(): S3UploadProvider;
|
|
60
|
+
//# sourceMappingURL=s3.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"s3.d.ts","sourceRoot":"","sources":["../../src/providers/s3.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAgBH,OAAO,KAAK,EACV,YAAY,EACZ,eAAe,EACf,eAAe,EACf,gBAAgB,EAChB,eAAe,EACf,cAAc,EACd,cAAc,EACd,YAAY,EACb,MAAM,UAAU,CAAC;AAElB,qBAAa,gBAAiB,YAAW,cAAc;IACrD,OAAO,CAAC,MAAM,CAAW;IACzB,OAAO,CAAC,MAAM,CAOZ;IACF,OAAO,CAAC,cAAc,CAA+C;gBAEzD,MAAM,EAAE,gBAAgB;IAwBpC;;OAEG;IACH,OAAO,CAAC,QAAQ;IAQhB;;OAEG;IACG,qBAAqB,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC;IA2B3E;;OAEG;IACG,qBAAqB,CAAC,MAAM,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAgE9F;;OAEG;YACW,mBAAmB;IAUjC;;OAEG;IACG,uBAAuB,CAC3B,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,YAAY,EAAE,GACpB,OAAO,CAAC,cAAc,CAAC;IAuC1B;;OAEG;IACG,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBxE;;OAEG;IACG,cAAc,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC;IA4BpE;;OAEG;IACG,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW5D;;OAEG;IACG,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,YAAY,GAAE,MAAa,GAAG,OAAO,CAAC,MAAM,CAAC;IAS/F;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAI3B;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAK7B;AAED;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,gBAAgB,CAoBnD"}
|
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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,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.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"private": false,
|
|
5
|
-
"license": "
|
|
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.
|
|
46
|
+
"@nebutra/logger": "0.1.1"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@vercel/blob": ">=0.23.0",
|