@uploadbox/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builder.d.ts +11 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/builder.js +27 -0
- package/dist/builder.js.map +1 -0
- package/dist/create-uploadbox.d.ts +13 -0
- package/dist/create-uploadbox.d.ts.map +1 -0
- package/dist/create-uploadbox.js +31 -0
- package/dist/create-uploadbox.js.map +1 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +53 -0
- package/dist/errors.js.map +1 -0
- package/dist/file-types.d.ts +6 -0
- package/dist/file-types.d.ts.map +1 -0
- package/dist/file-types.js +39 -0
- package/dist/file-types.js.map +1 -0
- package/dist/hooks/image-resize.d.ts +24 -0
- package/dist/hooks/image-resize.d.ts.map +1 -0
- package/dist/hooks/image-resize.js +78 -0
- package/dist/hooks/image-resize.js.map +1 -0
- package/dist/hooks/virus-scan.d.ts +24 -0
- package/dist/hooks/virus-scan.d.ts.map +1 -0
- package/dist/hooks/virus-scan.js +69 -0
- package/dist/hooks/virus-scan.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/processing.d.ts +25 -0
- package/dist/processing.d.ts.map +1 -0
- package/dist/processing.js +2 -0
- package/dist/processing.js.map +1 -0
- package/dist/s3-multipart.d.ts +12 -0
- package/dist/s3-multipart.d.ts.map +1 -0
- package/dist/s3-multipart.js +72 -0
- package/dist/s3-multipart.js.map +1 -0
- package/dist/s3.d.ts +20 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +95 -0
- package/dist/s3.js.map +1 -0
- package/dist/server-api.d.ts +43 -0
- package/dist/server-api.d.ts.map +1 -0
- package/dist/server-api.js +48 -0
- package/dist/server-api.js.map +1 -0
- package/dist/server-upload.d.ts +19 -0
- package/dist/server-upload.d.ts.map +1 -0
- package/dist/server-upload.js +53 -0
- package/dist/server-upload.js.map +1 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +50 -0
- package/dist/utils.js.map +1 -0
- package/dist/validation.d.ts +3 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +44 -0
- package/dist/validation.js.map +1 -0
- package/package.json +58 -0
- package/src/builder.ts +39 -0
- package/src/create-uploadbox.ts +46 -0
- package/src/errors.ts +65 -0
- package/src/file-types.ts +37 -0
- package/src/hooks/image-resize.ts +102 -0
- package/src/hooks/virus-scan.ts +95 -0
- package/src/index.ts +38 -0
- package/src/processing.ts +28 -0
- package/src/s3-multipart.ts +107 -0
- package/src/s3.ts +142 -0
- package/src/server-api.ts +81 -0
- package/src/server-upload.ts +88 -0
- package/src/types.ts +105 -0
- package/src/utils.ts +54 -0
- package/src/validation.ts +52 -0
package/src/builder.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileRouteConfig,
|
|
3
|
+
FileRoute,
|
|
4
|
+
MiddlewareFn,
|
|
5
|
+
OnUploadCompleteFn,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
|
|
8
|
+
export class UploadBuilder<TMetadata = Record<string, unknown>, TReturn = unknown> {
|
|
9
|
+
/** @internal */ _config: FileRouteConfig;
|
|
10
|
+
/** @internal */ _middleware: MiddlewareFn<TMetadata>;
|
|
11
|
+
/** @internal */ _onUploadComplete: OnUploadCompleteFn<TMetadata, TReturn>;
|
|
12
|
+
|
|
13
|
+
constructor(config: FileRouteConfig) {
|
|
14
|
+
this._config = config;
|
|
15
|
+
this._middleware = (async () => ({}) as TMetadata) as MiddlewareFn<TMetadata>;
|
|
16
|
+
this._onUploadComplete = (async () => undefined) as unknown as OnUploadCompleteFn<TMetadata, TReturn>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
middleware<TNewMetadata>(fn: MiddlewareFn<TNewMetadata>): UploadBuilder<TNewMetadata, TReturn> {
|
|
20
|
+
const builder = new UploadBuilder<TNewMetadata, TReturn>(this._config);
|
|
21
|
+
builder._middleware = fn;
|
|
22
|
+
builder._onUploadComplete = this._onUploadComplete as unknown as OnUploadCompleteFn<TNewMetadata, TReturn>;
|
|
23
|
+
return builder;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
onUploadComplete<TNewReturn>(
|
|
27
|
+
fn: OnUploadCompleteFn<TMetadata, TNewReturn>
|
|
28
|
+
): FileRoute<TMetadata, TNewReturn> {
|
|
29
|
+
return {
|
|
30
|
+
_config: this._config,
|
|
31
|
+
_middleware: this._middleware,
|
|
32
|
+
_onUploadComplete: fn,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createUploadBuilder(config: FileRouteConfig): UploadBuilder {
|
|
38
|
+
return new UploadBuilder(config);
|
|
39
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { FileRouter, FileRouteConfig, UploadboxConfig } from "./types.js";
|
|
2
|
+
import { createUploadBuilder } from "./builder.js";
|
|
3
|
+
import { createS3Client } from "./s3.js";
|
|
4
|
+
import type { S3Client } from "@aws-sdk/client-s3";
|
|
5
|
+
|
|
6
|
+
export interface UploadboxInstance<TRouter extends FileRouter> {
|
|
7
|
+
router: TRouter;
|
|
8
|
+
s3Client: S3Client;
|
|
9
|
+
config: UploadboxConfig;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getConfigFromEnv(): UploadboxConfig {
|
|
13
|
+
return {
|
|
14
|
+
region: process.env.UPLOADBOX_AWS_REGION ?? "us-east-1",
|
|
15
|
+
bucket: process.env.UPLOADBOX_S3_BUCKET ?? "",
|
|
16
|
+
accessKeyId: process.env.UPLOADBOX_AWS_ACCESS_KEY_ID ?? "",
|
|
17
|
+
secretAccessKey: process.env.UPLOADBOX_AWS_SECRET_ACCESS_KEY ?? "",
|
|
18
|
+
cdnBaseUrl: process.env.UPLOADBOX_CDN_BASE_URL,
|
|
19
|
+
endpoint: process.env.UPLOADBOX_S3_ENDPOINT,
|
|
20
|
+
forcePathStyle: process.env.UPLOADBOX_S3_FORCE_PATH_STYLE === "true",
|
|
21
|
+
presignedUrlExpiry: process.env.UPLOADBOX_PRESIGNED_URL_EXPIRY
|
|
22
|
+
? parseInt(process.env.UPLOADBOX_PRESIGNED_URL_EXPIRY, 10)
|
|
23
|
+
: undefined,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createUploadbox<TRouter extends FileRouter>(opts: {
|
|
28
|
+
router: TRouter;
|
|
29
|
+
config?: Partial<UploadboxConfig>;
|
|
30
|
+
}): UploadboxInstance<TRouter> {
|
|
31
|
+
const envConfig = getConfigFromEnv();
|
|
32
|
+
const config: UploadboxConfig = { ...envConfig, ...opts.config };
|
|
33
|
+
|
|
34
|
+
const s3Client = createS3Client(config);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
router: opts.router,
|
|
38
|
+
s3Client,
|
|
39
|
+
config,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The `f` function — shorthand for creating a file route builder
|
|
44
|
+
export function f(config: FileRouteConfig) {
|
|
45
|
+
return createUploadBuilder(config);
|
|
46
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export class UploadboxError extends Error {
|
|
2
|
+
public readonly code: string;
|
|
3
|
+
public readonly statusCode: number;
|
|
4
|
+
|
|
5
|
+
constructor(code: string, message: string, statusCode: number = 400) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "UploadboxError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.statusCode = statusCode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
static fileTooLarge(maxSize: string) {
|
|
13
|
+
return new UploadboxError("FILE_TOO_LARGE", `File exceeds maximum size of ${maxSize}`, 413);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static invalidFileType(type: string) {
|
|
17
|
+
return new UploadboxError("INVALID_FILE_TYPE", `File type "${type}" is not allowed`, 415);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static tooManyFiles(max: number) {
|
|
21
|
+
return new UploadboxError("TOO_MANY_FILES", `Maximum ${max} files allowed`, 400);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static tooFewFiles(min: number) {
|
|
25
|
+
return new UploadboxError("TOO_FEW_FILES", `Minimum ${min} files required`, 400);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static routeNotFound(route: string) {
|
|
29
|
+
return new UploadboxError("ROUTE_NOT_FOUND", `Upload route "${route}" not found`, 404);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static uploadFailed(reason: string) {
|
|
33
|
+
return new UploadboxError("UPLOAD_FAILED", `Upload failed: ${reason}`, 500);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static unauthorized(message = "Unauthorized") {
|
|
37
|
+
return new UploadboxError("UNAUTHORIZED", message, 401);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static rateLimited(retryAfter: number) {
|
|
41
|
+
const err = new UploadboxError("RATE_LIMITED", `Rate limit exceeded. Retry after ${retryAfter} seconds`, 429);
|
|
42
|
+
(err as any).retryAfter = retryAfter;
|
|
43
|
+
return err;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static forbidden(message = "Forbidden") {
|
|
47
|
+
return new UploadboxError("FORBIDDEN", message, 403);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static quotaExceeded() {
|
|
51
|
+
return new UploadboxError("QUOTA_EXCEEDED", "Storage or upload quota exceeded", 413);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static s3Error(message: string) {
|
|
55
|
+
return new UploadboxError("S3_ERROR", `S3 error: ${message}`, 500);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
toJSON() {
|
|
59
|
+
return {
|
|
60
|
+
error: this.code,
|
|
61
|
+
message: this.message,
|
|
62
|
+
statusCode: this.statusCode,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { FileType } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const FILE_TYPE_MIME_MAP: Record<FileType, string[]> = {
|
|
4
|
+
image: ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/avif", "image/heic", "image/heif"],
|
|
5
|
+
video: ["video/mp4", "video/webm", "video/ogg", "video/quicktime", "video/x-msvideo"],
|
|
6
|
+
audio: ["audio/mpeg", "audio/ogg", "audio/wav", "audio/webm", "audio/aac", "audio/flac"],
|
|
7
|
+
pdf: ["application/pdf"],
|
|
8
|
+
text: ["text/plain", "text/csv", "text/html", "text/css", "text/javascript", "application/json", "application/xml"],
|
|
9
|
+
blob: ["application/octet-stream"],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_MAX_FILE_SIZES: Record<FileType, string> = {
|
|
13
|
+
image: "4MB",
|
|
14
|
+
video: "64MB",
|
|
15
|
+
audio: "16MB",
|
|
16
|
+
pdf: "16MB",
|
|
17
|
+
text: "1MB",
|
|
18
|
+
blob: "8MB",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function getFileTypeFromMime(mimeType: string): FileType | null {
|
|
22
|
+
for (const [fileType, mimes] of Object.entries(FILE_TYPE_MIME_MAP)) {
|
|
23
|
+
if (mimes.includes(mimeType)) return fileType as FileType;
|
|
24
|
+
}
|
|
25
|
+
// Fallback: check prefix
|
|
26
|
+
if (mimeType.startsWith("image/")) return "image";
|
|
27
|
+
if (mimeType.startsWith("video/")) return "video";
|
|
28
|
+
if (mimeType.startsWith("audio/")) return "audio";
|
|
29
|
+
if (mimeType.startsWith("text/")) return "text";
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isFileTypeAllowed(mimeType: string, allowedTypes: FileType[]): boolean {
|
|
34
|
+
if (allowedTypes.includes("blob")) return true;
|
|
35
|
+
const fileType = getFileTypeFromMime(mimeType);
|
|
36
|
+
return fileType !== null && allowedTypes.includes(fileType);
|
|
37
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ProcessingHook, ProcessingContext, ProcessingHookResult } from "../processing.js";
|
|
2
|
+
|
|
3
|
+
interface ImageResizeOptions {
|
|
4
|
+
maxWidth?: number;
|
|
5
|
+
maxHeight?: number;
|
|
6
|
+
quality?: number;
|
|
7
|
+
format?: "jpeg" | "png" | "webp";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Example image resize hook using sharp.
|
|
12
|
+
* Install sharp as a dependency to use this hook:
|
|
13
|
+
* npm install sharp
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* import { createImageResizeHook } from "@uploadbox/core/hooks/image-resize";
|
|
17
|
+
*
|
|
18
|
+
* const resizeHook = createImageResizeHook({
|
|
19
|
+
* maxWidth: 1920,
|
|
20
|
+
* maxHeight: 1080,
|
|
21
|
+
* quality: 80,
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
export function createImageResizeHook(options: ImageResizeOptions = {}): ProcessingHook {
|
|
25
|
+
const {
|
|
26
|
+
maxWidth = 1920,
|
|
27
|
+
maxHeight = 1080,
|
|
28
|
+
quality = 80,
|
|
29
|
+
format = "webp",
|
|
30
|
+
} = options;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
name: "image-resize",
|
|
34
|
+
timeoutMs: 60000,
|
|
35
|
+
|
|
36
|
+
shouldRun(ctx: ProcessingContext): boolean {
|
|
37
|
+
return ctx.file.type.startsWith("image/");
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async run(ctx: ProcessingContext): Promise<ProcessingHookResult> {
|
|
41
|
+
try {
|
|
42
|
+
// Dynamic import to make sharp optional
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
44
|
+
let sharp: any;
|
|
45
|
+
try {
|
|
46
|
+
sharp = await (Function('return import("sharp")')() as Promise<any>);
|
|
47
|
+
} catch {
|
|
48
|
+
return {
|
|
49
|
+
success: false,
|
|
50
|
+
error: "sharp is not installed. Run: npm install sharp",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const { GetObjectCommand, PutObjectCommand } = await import("@aws-sdk/client-s3");
|
|
55
|
+
|
|
56
|
+
// Download file from S3
|
|
57
|
+
const response = await ctx.s3Client.send(
|
|
58
|
+
new GetObjectCommand({
|
|
59
|
+
Bucket: ctx.config.bucket,
|
|
60
|
+
Key: ctx.file.key,
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const body = await response.Body?.transformToByteArray();
|
|
65
|
+
if (!body) {
|
|
66
|
+
return { success: false, error: "Failed to download file from S3" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Resize image
|
|
70
|
+
const sharpFn = sharp.default ?? sharp;
|
|
71
|
+
const resized = await sharpFn(Buffer.from(body))
|
|
72
|
+
.resize(maxWidth, maxHeight, { fit: "inside", withoutEnlargement: true })
|
|
73
|
+
.toFormat(format, { quality })
|
|
74
|
+
.toBuffer();
|
|
75
|
+
|
|
76
|
+
// Upload resized image back to S3
|
|
77
|
+
await ctx.s3Client.send(
|
|
78
|
+
new PutObjectCommand({
|
|
79
|
+
Bucket: ctx.config.bucket,
|
|
80
|
+
Key: ctx.file.key,
|
|
81
|
+
Body: resized,
|
|
82
|
+
ContentType: `image/${format}`,
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
success: true,
|
|
88
|
+
data: {
|
|
89
|
+
originalSize: body.length,
|
|
90
|
+
resizedSize: resized.length,
|
|
91
|
+
format,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return {
|
|
96
|
+
success: false,
|
|
97
|
+
error: (err as Error).message,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ProcessingHook, ProcessingContext, ProcessingHookResult } from "../processing.js";
|
|
2
|
+
|
|
3
|
+
interface VirusScanOptions {
|
|
4
|
+
/** ClamAV REST API endpoint (e.g., http://localhost:3310/scan) */
|
|
5
|
+
clamavUrl: string;
|
|
6
|
+
/** Whether to delete the file if a virus is found */
|
|
7
|
+
deleteOnDetection?: boolean;
|
|
8
|
+
/** Timeout in ms for the scan request */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Example virus scan hook using ClamAV REST API.
|
|
14
|
+
* Requires a running ClamAV REST service.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* import { createVirusScanHook } from "@uploadbox/core/hooks/virus-scan";
|
|
18
|
+
*
|
|
19
|
+
* const scanHook = createVirusScanHook({
|
|
20
|
+
* clamavUrl: "http://localhost:3310/scan",
|
|
21
|
+
* deleteOnDetection: true,
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
export function createVirusScanHook(options: VirusScanOptions): ProcessingHook {
|
|
25
|
+
const {
|
|
26
|
+
clamavUrl,
|
|
27
|
+
deleteOnDetection = true,
|
|
28
|
+
timeoutMs = 30000,
|
|
29
|
+
} = options;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name: "virus-scan",
|
|
33
|
+
timeoutMs,
|
|
34
|
+
|
|
35
|
+
shouldRun(): boolean {
|
|
36
|
+
// Scan all uploaded files
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async run(ctx: ProcessingContext): Promise<ProcessingHookResult> {
|
|
41
|
+
try {
|
|
42
|
+
const { GetObjectCommand, DeleteObjectCommand } = await import("@aws-sdk/client-s3");
|
|
43
|
+
|
|
44
|
+
// Download file from S3
|
|
45
|
+
const response = await ctx.s3Client.send(
|
|
46
|
+
new GetObjectCommand({
|
|
47
|
+
Bucket: ctx.config.bucket,
|
|
48
|
+
Key: ctx.file.key,
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const body = await response.Body?.transformToByteArray();
|
|
53
|
+
if (!body) {
|
|
54
|
+
return { success: false, error: "Failed to download file from S3" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Send to ClamAV for scanning
|
|
58
|
+
const scanResponse = await fetch(clamavUrl, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
body: Buffer.from(body),
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "application/octet-stream",
|
|
63
|
+
},
|
|
64
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const scanResult = await scanResponse.text();
|
|
68
|
+
const isClean = scanResponse.ok && !scanResult.toLowerCase().includes("found");
|
|
69
|
+
|
|
70
|
+
if (!isClean && deleteOnDetection) {
|
|
71
|
+
await ctx.s3Client.send(
|
|
72
|
+
new DeleteObjectCommand({
|
|
73
|
+
Bucket: ctx.config.bucket,
|
|
74
|
+
Key: ctx.file.key,
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
success: isClean,
|
|
81
|
+
data: {
|
|
82
|
+
clean: isClean,
|
|
83
|
+
scanResult: scanResult.slice(0, 500),
|
|
84
|
+
},
|
|
85
|
+
error: isClean ? undefined : `Virus detected: ${scanResult.slice(0, 200)}`,
|
|
86
|
+
};
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return {
|
|
89
|
+
success: false,
|
|
90
|
+
error: `Virus scan failed: ${(err as Error).message}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export { createUploadbox, f } from "./create-uploadbox.js";
|
|
2
|
+
export { UploadboxApi } from "./server-api.js";
|
|
3
|
+
export { UploadboxError } from "./errors.js";
|
|
4
|
+
export { createUploadBuilder } from "./builder.js";
|
|
5
|
+
export { validateFiles } from "./validation.js";
|
|
6
|
+
export { createS3Client, generatePresignedPutUrl, generatePresignedGetUrl, headObject, deleteObject, deleteObjects, listObjects } from "./s3.js";
|
|
7
|
+
export { parseFileSize, formatFileSize, generateFileKey, generateUploadId } from "./utils.js";
|
|
8
|
+
export { FILE_TYPE_MIME_MAP, DEFAULT_MAX_FILE_SIZES, getFileTypeFromMime, isFileTypeAllowed } from "./file-types.js";
|
|
9
|
+
export { serverUpload, serverUploadMany } from "./server-upload.js";
|
|
10
|
+
export type { ServerUploadInput, ServerUploadResult } from "./server-upload.js";
|
|
11
|
+
export {
|
|
12
|
+
createMultipartUpload,
|
|
13
|
+
generatePresignedPartUrls,
|
|
14
|
+
completeMultipartUpload,
|
|
15
|
+
abortMultipartUpload,
|
|
16
|
+
} from "./s3-multipart.js";
|
|
17
|
+
export type {
|
|
18
|
+
FileType,
|
|
19
|
+
FileSize,
|
|
20
|
+
ACL,
|
|
21
|
+
ContentDisposition,
|
|
22
|
+
FileRouteConfig,
|
|
23
|
+
FileInfo,
|
|
24
|
+
UploadedFileData,
|
|
25
|
+
MiddlewareFn,
|
|
26
|
+
OnUploadCompleteFn,
|
|
27
|
+
FileRoute,
|
|
28
|
+
FileRouter,
|
|
29
|
+
PresignedUrlResponse,
|
|
30
|
+
UploadCompleteResponse,
|
|
31
|
+
UploadboxConfig,
|
|
32
|
+
RouterConfig,
|
|
33
|
+
AuthContext,
|
|
34
|
+
MultipartPresignedResponse,
|
|
35
|
+
CompletedPartInfo,
|
|
36
|
+
} from "./types.js";
|
|
37
|
+
export { MULTIPART_THRESHOLD, DEFAULT_PART_SIZE } from "./types.js";
|
|
38
|
+
export type { ProcessingHook, ProcessingContext, ProcessingHookResult, ProcessingPipelineConfig } from "./processing.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { UploadedFileData, UploadboxConfig } from "./types.js";
|
|
2
|
+
import type { S3Client } from "@aws-sdk/client-s3";
|
|
3
|
+
|
|
4
|
+
export interface ProcessingContext {
|
|
5
|
+
file: UploadedFileData;
|
|
6
|
+
metadata: Record<string, unknown>;
|
|
7
|
+
s3Client: S3Client;
|
|
8
|
+
config: UploadboxConfig;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ProcessingHookResult {
|
|
12
|
+
success: boolean;
|
|
13
|
+
data?: Record<string, unknown>;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ProcessingHook {
|
|
18
|
+
name: string;
|
|
19
|
+
shouldRun: (ctx: ProcessingContext) => boolean;
|
|
20
|
+
run: (ctx: ProcessingContext) => Promise<ProcessingHookResult>;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProcessingPipelineConfig {
|
|
25
|
+
hooks: ProcessingHook[];
|
|
26
|
+
mode?: "sequential" | "parallel";
|
|
27
|
+
continueOnError?: boolean;
|
|
28
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
S3Client,
|
|
3
|
+
CreateMultipartUploadCommand,
|
|
4
|
+
UploadPartCommand,
|
|
5
|
+
CompleteMultipartUploadCommand,
|
|
6
|
+
AbortMultipartUploadCommand,
|
|
7
|
+
} from "@aws-sdk/client-s3";
|
|
8
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
9
|
+
import { UploadboxError } from "./errors.js";
|
|
10
|
+
|
|
11
|
+
export async function createMultipartUpload(
|
|
12
|
+
client: S3Client,
|
|
13
|
+
bucket: string,
|
|
14
|
+
key: string,
|
|
15
|
+
contentType: string
|
|
16
|
+
): Promise<string> {
|
|
17
|
+
try {
|
|
18
|
+
const response = await client.send(
|
|
19
|
+
new CreateMultipartUploadCommand({
|
|
20
|
+
Bucket: bucket,
|
|
21
|
+
Key: key,
|
|
22
|
+
ContentType: contentType,
|
|
23
|
+
})
|
|
24
|
+
);
|
|
25
|
+
if (!response.UploadId) {
|
|
26
|
+
throw new Error("No UploadId returned from S3");
|
|
27
|
+
}
|
|
28
|
+
return response.UploadId;
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err instanceof UploadboxError) throw err;
|
|
31
|
+
throw UploadboxError.s3Error((err as Error).message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function generatePresignedPartUrls(
|
|
36
|
+
client: S3Client,
|
|
37
|
+
bucket: string,
|
|
38
|
+
key: string,
|
|
39
|
+
uploadId: string,
|
|
40
|
+
partNumbers: number[],
|
|
41
|
+
expiresIn: number = 3600
|
|
42
|
+
): Promise<{ partNumber: number; url: string }[]> {
|
|
43
|
+
try {
|
|
44
|
+
return await Promise.all(
|
|
45
|
+
partNumbers.map(async (partNumber) => {
|
|
46
|
+
const command = new UploadPartCommand({
|
|
47
|
+
Bucket: bucket,
|
|
48
|
+
Key: key,
|
|
49
|
+
UploadId: uploadId,
|
|
50
|
+
PartNumber: partNumber,
|
|
51
|
+
});
|
|
52
|
+
const url = await getSignedUrl(client, command, { expiresIn });
|
|
53
|
+
return { partNumber, url };
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw UploadboxError.s3Error((err as Error).message);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function completeMultipartUpload(
|
|
62
|
+
client: S3Client,
|
|
63
|
+
bucket: string,
|
|
64
|
+
key: string,
|
|
65
|
+
uploadId: string,
|
|
66
|
+
parts: { partNumber: number; etag: string }[]
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
try {
|
|
69
|
+
await client.send(
|
|
70
|
+
new CompleteMultipartUploadCommand({
|
|
71
|
+
Bucket: bucket,
|
|
72
|
+
Key: key,
|
|
73
|
+
UploadId: uploadId,
|
|
74
|
+
MultipartUpload: {
|
|
75
|
+
Parts: parts
|
|
76
|
+
.sort((a, b) => a.partNumber - b.partNumber)
|
|
77
|
+
.map((p) => ({
|
|
78
|
+
PartNumber: p.partNumber,
|
|
79
|
+
ETag: p.etag,
|
|
80
|
+
})),
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
throw UploadboxError.s3Error((err as Error).message);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function abortMultipartUpload(
|
|
90
|
+
client: S3Client,
|
|
91
|
+
bucket: string,
|
|
92
|
+
key: string,
|
|
93
|
+
uploadId: string
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
try {
|
|
96
|
+
await client.send(
|
|
97
|
+
new AbortMultipartUploadCommand({
|
|
98
|
+
Bucket: bucket,
|
|
99
|
+
Key: key,
|
|
100
|
+
UploadId: uploadId,
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Ignore errors when aborting — the upload may already be aborted
|
|
105
|
+
console.warn("[uploadbox] Failed to abort multipart upload:", (err as Error).message);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/s3.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
S3Client,
|
|
3
|
+
PutObjectCommand,
|
|
4
|
+
HeadObjectCommand,
|
|
5
|
+
DeleteObjectCommand,
|
|
6
|
+
DeleteObjectsCommand,
|
|
7
|
+
ListObjectsV2Command,
|
|
8
|
+
GetObjectCommand,
|
|
9
|
+
} from "@aws-sdk/client-s3";
|
|
10
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
11
|
+
import type { UploadboxConfig } from "./types.js";
|
|
12
|
+
import { UploadboxError } from "./errors.js";
|
|
13
|
+
|
|
14
|
+
export function createS3Client(config: UploadboxConfig): S3Client {
|
|
15
|
+
return new S3Client({
|
|
16
|
+
region: config.region ?? "us-east-1",
|
|
17
|
+
credentials: {
|
|
18
|
+
accessKeyId: config.accessKeyId,
|
|
19
|
+
secretAccessKey: config.secretAccessKey,
|
|
20
|
+
},
|
|
21
|
+
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
|
22
|
+
...(config.forcePathStyle ? { forcePathStyle: true } : {}),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function generatePresignedPutUrl(
|
|
27
|
+
client: S3Client,
|
|
28
|
+
bucket: string,
|
|
29
|
+
key: string,
|
|
30
|
+
contentType: string,
|
|
31
|
+
contentLength: number,
|
|
32
|
+
expiresIn: number = 3600
|
|
33
|
+
): Promise<string> {
|
|
34
|
+
try {
|
|
35
|
+
const command = new PutObjectCommand({
|
|
36
|
+
Bucket: bucket,
|
|
37
|
+
Key: key,
|
|
38
|
+
ContentType: contentType,
|
|
39
|
+
ContentLength: contentLength,
|
|
40
|
+
});
|
|
41
|
+
return await getSignedUrl(client, command, { expiresIn });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw UploadboxError.s3Error((err as Error).message);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function generatePresignedGetUrl(
|
|
48
|
+
client: S3Client,
|
|
49
|
+
bucket: string,
|
|
50
|
+
key: string,
|
|
51
|
+
expiresIn: number = 3600
|
|
52
|
+
): Promise<string> {
|
|
53
|
+
try {
|
|
54
|
+
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
|
55
|
+
return await getSignedUrl(client, command, { expiresIn });
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw UploadboxError.s3Error((err as Error).message);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function headObject(
|
|
62
|
+
client: S3Client,
|
|
63
|
+
bucket: string,
|
|
64
|
+
key: string
|
|
65
|
+
): Promise<{ contentLength: number; contentType: string } | null> {
|
|
66
|
+
try {
|
|
67
|
+
const response = await client.send(
|
|
68
|
+
new HeadObjectCommand({ Bucket: bucket, Key: key })
|
|
69
|
+
);
|
|
70
|
+
return {
|
|
71
|
+
contentLength: response.ContentLength ?? 0,
|
|
72
|
+
contentType: response.ContentType ?? "application/octet-stream",
|
|
73
|
+
};
|
|
74
|
+
} catch (err: any) {
|
|
75
|
+
if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
throw UploadboxError.s3Error(err.message);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function deleteObject(
|
|
83
|
+
client: S3Client,
|
|
84
|
+
bucket: string,
|
|
85
|
+
key: string
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
try {
|
|
88
|
+
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
throw UploadboxError.s3Error((err as Error).message);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function deleteObjects(
|
|
95
|
+
client: S3Client,
|
|
96
|
+
bucket: string,
|
|
97
|
+
keys: string[]
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
if (keys.length === 0) return;
|
|
100
|
+
try {
|
|
101
|
+
await client.send(
|
|
102
|
+
new DeleteObjectsCommand({
|
|
103
|
+
Bucket: bucket,
|
|
104
|
+
Delete: { Objects: keys.map((Key) => ({ Key })) },
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw UploadboxError.s3Error((err as Error).message);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function listObjects(
|
|
113
|
+
client: S3Client,
|
|
114
|
+
bucket: string,
|
|
115
|
+
prefix?: string,
|
|
116
|
+
limit: number = 1000,
|
|
117
|
+
continuationToken?: string
|
|
118
|
+
): Promise<{
|
|
119
|
+
keys: { key: string; size: number; lastModified: Date }[];
|
|
120
|
+
nextToken?: string;
|
|
121
|
+
}> {
|
|
122
|
+
try {
|
|
123
|
+
const response = await client.send(
|
|
124
|
+
new ListObjectsV2Command({
|
|
125
|
+
Bucket: bucket,
|
|
126
|
+
Prefix: prefix,
|
|
127
|
+
MaxKeys: limit,
|
|
128
|
+
ContinuationToken: continuationToken,
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
return {
|
|
132
|
+
keys: (response.Contents ?? []).map((obj) => ({
|
|
133
|
+
key: obj.Key!,
|
|
134
|
+
size: obj.Size ?? 0,
|
|
135
|
+
lastModified: obj.LastModified ?? new Date(),
|
|
136
|
+
})),
|
|
137
|
+
nextToken: response.NextContinuationToken,
|
|
138
|
+
};
|
|
139
|
+
} catch (err) {
|
|
140
|
+
throw UploadboxError.s3Error((err as Error).message);
|
|
141
|
+
}
|
|
142
|
+
}
|