@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
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
import type { UploadboxConfig, ACL } from "./types.js";
|
|
3
|
+
import {
|
|
4
|
+
listObjects,
|
|
5
|
+
deleteObject,
|
|
6
|
+
deleteObjects,
|
|
7
|
+
generatePresignedGetUrl,
|
|
8
|
+
headObject,
|
|
9
|
+
} from "./s3.js";
|
|
10
|
+
import { serverUpload, serverUploadMany, type ServerUploadInput, type ServerUploadResult } from "./server-upload.js";
|
|
11
|
+
|
|
12
|
+
export interface FileRecord {
|
|
13
|
+
id: string;
|
|
14
|
+
key: string;
|
|
15
|
+
name: string;
|
|
16
|
+
size: number;
|
|
17
|
+
type: string;
|
|
18
|
+
url: string;
|
|
19
|
+
acl: ACL;
|
|
20
|
+
status: "pending" | "complete";
|
|
21
|
+
createdAt: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class UploadboxApi {
|
|
25
|
+
constructor(
|
|
26
|
+
private s3Client: S3Client,
|
|
27
|
+
private config: UploadboxConfig
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
private getFileUrl(key: string): string {
|
|
31
|
+
if (this.config.cdnBaseUrl) {
|
|
32
|
+
return `${this.config.cdnBaseUrl.replace(/\/$/, "")}/${key}`;
|
|
33
|
+
}
|
|
34
|
+
if (this.config.endpoint) {
|
|
35
|
+
const base = this.config.endpoint.replace(/\/$/, "");
|
|
36
|
+
if (this.config.forcePathStyle) {
|
|
37
|
+
return `${base}/${this.config.bucket}/${key}`;
|
|
38
|
+
}
|
|
39
|
+
return `${base}/${key}`;
|
|
40
|
+
}
|
|
41
|
+
return `https://${this.config.bucket}.s3.${this.config.region ?? "us-east-1"}.amazonaws.com/${key}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async listFiles(opts?: { prefix?: string; limit?: number; continuationToken?: string }) {
|
|
45
|
+
return listObjects(
|
|
46
|
+
this.s3Client,
|
|
47
|
+
this.config.bucket,
|
|
48
|
+
opts?.prefix,
|
|
49
|
+
opts?.limit,
|
|
50
|
+
opts?.continuationToken
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async deleteFile(key: string): Promise<void> {
|
|
55
|
+
await deleteObject(this.s3Client, this.config.bucket, key);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async deleteFiles(keys: string[]): Promise<void> {
|
|
59
|
+
await deleteObjects(this.s3Client, this.config.bucket, keys);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async getSignedUrl(key: string, expiresIn?: number): Promise<string> {
|
|
63
|
+
return generatePresignedGetUrl(this.s3Client, this.config.bucket, key, expiresIn);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async verifyUpload(key: string): Promise<{ contentLength: number; contentType: string } | null> {
|
|
67
|
+
return headObject(this.s3Client, this.config.bucket, key);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
buildFileUrl(key: string): string {
|
|
71
|
+
return this.getFileUrl(key);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async uploadFile(input: ServerUploadInput): Promise<ServerUploadResult> {
|
|
75
|
+
return serverUpload(this.s3Client, this.config, input);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async uploadFiles(inputs: ServerUploadInput[], concurrency?: number): Promise<ServerUploadResult[]> {
|
|
79
|
+
return serverUploadMany(this.s3Client, this.config, inputs, concurrency);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
2
|
+
import type { S3Client } from "@aws-sdk/client-s3";
|
|
3
|
+
import type { UploadboxConfig } from "./types.js";
|
|
4
|
+
import { generateFileKey, getContentTypeFromExt } from "./utils.js";
|
|
5
|
+
import { UploadboxError } from "./errors.js";
|
|
6
|
+
import type { Readable } from "stream";
|
|
7
|
+
|
|
8
|
+
export interface ServerUploadInput {
|
|
9
|
+
name: string;
|
|
10
|
+
body: Buffer | Uint8Array | Readable;
|
|
11
|
+
contentType?: string;
|
|
12
|
+
metadata?: Record<string, string>;
|
|
13
|
+
acl?: "public-read" | "private";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ServerUploadResult {
|
|
17
|
+
key: string;
|
|
18
|
+
name: string;
|
|
19
|
+
url: string;
|
|
20
|
+
contentType: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildUrl(config: UploadboxConfig, key: string): string {
|
|
24
|
+
if (config.cdnBaseUrl) {
|
|
25
|
+
return `${config.cdnBaseUrl.replace(/\/$/, "")}/${key}`;
|
|
26
|
+
}
|
|
27
|
+
if (config.endpoint) {
|
|
28
|
+
const base = config.endpoint.replace(/\/$/, "");
|
|
29
|
+
if (config.forcePathStyle) {
|
|
30
|
+
return `${base}/${config.bucket}/${key}`;
|
|
31
|
+
}
|
|
32
|
+
return `${base}/${key}`;
|
|
33
|
+
}
|
|
34
|
+
return `https://${config.bucket}.s3.${config.region ?? "us-east-1"}.amazonaws.com/${key}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function serverUpload(
|
|
38
|
+
s3Client: S3Client,
|
|
39
|
+
config: UploadboxConfig,
|
|
40
|
+
input: ServerUploadInput
|
|
41
|
+
): Promise<ServerUploadResult> {
|
|
42
|
+
const key = generateFileKey(input.name);
|
|
43
|
+
const contentType = input.contentType ?? getContentTypeFromExt(input.name);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await s3Client.send(
|
|
47
|
+
new PutObjectCommand({
|
|
48
|
+
Bucket: config.bucket,
|
|
49
|
+
Key: key,
|
|
50
|
+
Body: input.body,
|
|
51
|
+
ContentType: contentType,
|
|
52
|
+
...(input.acl ? { ACL: input.acl } : {}),
|
|
53
|
+
...(input.metadata ? { Metadata: input.metadata } : {}),
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw UploadboxError.s3Error((err as Error).message);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
key,
|
|
62
|
+
name: input.name,
|
|
63
|
+
url: buildUrl(config, key),
|
|
64
|
+
contentType,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function serverUploadMany(
|
|
69
|
+
s3Client: S3Client,
|
|
70
|
+
config: UploadboxConfig,
|
|
71
|
+
inputs: ServerUploadInput[],
|
|
72
|
+
concurrency: number = 5
|
|
73
|
+
): Promise<ServerUploadResult[]> {
|
|
74
|
+
const results: ServerUploadResult[] = [];
|
|
75
|
+
const queue = [...inputs];
|
|
76
|
+
|
|
77
|
+
async function worker() {
|
|
78
|
+
while (queue.length > 0) {
|
|
79
|
+
const input = queue.shift()!;
|
|
80
|
+
results.push(await serverUpload(s3Client, config, input));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const workers = Array.from({ length: Math.min(concurrency, inputs.length) }, () => worker());
|
|
85
|
+
await Promise.all(workers);
|
|
86
|
+
|
|
87
|
+
return results;
|
|
88
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export type FileType = "image" | "video" | "audio" | "pdf" | "text" | "blob";
|
|
2
|
+
export type FileSize = `${number}${"B" | "KB" | "MB" | "GB"}`;
|
|
3
|
+
export type ACL = "public-read" | "private";
|
|
4
|
+
export type ContentDisposition = "inline" | "attachment";
|
|
5
|
+
|
|
6
|
+
export type FileRouteConfig = {
|
|
7
|
+
[K in FileType]?: {
|
|
8
|
+
maxFileSize?: FileSize;
|
|
9
|
+
maxFileCount?: number;
|
|
10
|
+
minFileCount?: number;
|
|
11
|
+
acl?: ACL;
|
|
12
|
+
contentDisposition?: ContentDisposition;
|
|
13
|
+
presignedUrlExpiry?: number;
|
|
14
|
+
defaultTtlSeconds?: number;
|
|
15
|
+
maxTtlSeconds?: number;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface FileInfo {
|
|
20
|
+
name: string;
|
|
21
|
+
size: number;
|
|
22
|
+
type: string;
|
|
23
|
+
customMetadata?: Record<string, string>;
|
|
24
|
+
ttlSeconds?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UploadedFileData {
|
|
28
|
+
key: string;
|
|
29
|
+
name: string;
|
|
30
|
+
size: number;
|
|
31
|
+
type: string;
|
|
32
|
+
url: string;
|
|
33
|
+
acl: ACL;
|
|
34
|
+
customMetadata?: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AuthContext {
|
|
38
|
+
apiKeyId: string;
|
|
39
|
+
apiKeyName: string;
|
|
40
|
+
projectId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type MiddlewareFn<TMetadata> = (ctx: {
|
|
44
|
+
req: Request;
|
|
45
|
+
files: FileInfo[];
|
|
46
|
+
auth?: AuthContext;
|
|
47
|
+
}) => Promise<TMetadata> | TMetadata;
|
|
48
|
+
|
|
49
|
+
export type OnUploadCompleteFn<TMetadata, TReturn> = (ctx: {
|
|
50
|
+
metadata: TMetadata;
|
|
51
|
+
file: UploadedFileData;
|
|
52
|
+
}) => Promise<TReturn> | TReturn;
|
|
53
|
+
|
|
54
|
+
export interface FileRoute<TMetadata = Record<string, unknown>, TReturn = unknown> {
|
|
55
|
+
_config: FileRouteConfig;
|
|
56
|
+
_middleware: MiddlewareFn<TMetadata>;
|
|
57
|
+
_onUploadComplete: OnUploadCompleteFn<TMetadata, TReturn>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type FileRouter = Record<string, FileRoute<any, any>>;
|
|
61
|
+
|
|
62
|
+
export interface PresignedUrlResponse {
|
|
63
|
+
uploadId: string;
|
|
64
|
+
key: string;
|
|
65
|
+
url: string;
|
|
66
|
+
fields?: Record<string, string>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface UploadCompleteResponse<TReturn = unknown> {
|
|
70
|
+
file: UploadedFileData;
|
|
71
|
+
serverData: TReturn;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface UploadboxConfig {
|
|
75
|
+
region?: string;
|
|
76
|
+
bucket: string;
|
|
77
|
+
accessKeyId: string;
|
|
78
|
+
secretAccessKey: string;
|
|
79
|
+
cdnBaseUrl?: string;
|
|
80
|
+
endpoint?: string;
|
|
81
|
+
forcePathStyle?: boolean;
|
|
82
|
+
presignedUrlExpiry?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type RouterConfig = {
|
|
86
|
+
[routeKey: string]: {
|
|
87
|
+
config: FileRouteConfig;
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const MULTIPART_THRESHOLD = 10 * 1024 * 1024; // 10MB
|
|
92
|
+
export const DEFAULT_PART_SIZE = 10 * 1024 * 1024; // 10MB
|
|
93
|
+
|
|
94
|
+
export interface MultipartPresignedResponse {
|
|
95
|
+
fileKey: string;
|
|
96
|
+
uploadId: string;
|
|
97
|
+
parts: { partNumber: number; url: string }[];
|
|
98
|
+
partSize: number;
|
|
99
|
+
totalParts: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface CompletedPartInfo {
|
|
103
|
+
partNumber: number;
|
|
104
|
+
etag: string;
|
|
105
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { FileSize } from "./types.js";
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
|
|
4
|
+
const SIZE_UNITS: Record<string, number> = {
|
|
5
|
+
B: 1,
|
|
6
|
+
KB: 1024,
|
|
7
|
+
MB: 1024 * 1024,
|
|
8
|
+
GB: 1024 * 1024 * 1024,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function parseFileSize(size: FileSize): number {
|
|
12
|
+
const match = size.match(/^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/);
|
|
13
|
+
if (!match) throw new Error(`Invalid file size format: ${size}`);
|
|
14
|
+
const value = parseFloat(match[1]!);
|
|
15
|
+
const unit = match[2]!;
|
|
16
|
+
return Math.floor(value * SIZE_UNITS[unit]!);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatFileSize(bytes: number): string {
|
|
20
|
+
if (bytes === 0) return "0 B";
|
|
21
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
22
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
23
|
+
const value = bytes / Math.pow(1024, i);
|
|
24
|
+
return `${value.toFixed(value % 1 === 0 ? 0 : 1)} ${units[i]}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function generateFileKey(originalName: string): string {
|
|
28
|
+
const id = randomBytes(16).toString("hex");
|
|
29
|
+
const ext = originalName.includes(".") ? originalName.slice(originalName.lastIndexOf(".")) : "";
|
|
30
|
+
const safeName = originalName
|
|
31
|
+
.replace(/\.[^.]+$/, "")
|
|
32
|
+
.replace(/[^a-zA-Z0-9-_]/g, "-")
|
|
33
|
+
.replace(/-+/g, "-")
|
|
34
|
+
.slice(0, 100);
|
|
35
|
+
return `${safeName}-${id}${ext}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function generateUploadId(): string {
|
|
39
|
+
return randomBytes(12).toString("base64url");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getContentTypeFromExt(filename: string): string {
|
|
43
|
+
const ext = filename.slice(filename.lastIndexOf(".") + 1).toLowerCase();
|
|
44
|
+
const map: Record<string, string> = {
|
|
45
|
+
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
|
|
46
|
+
webp: "image/webp", svg: "image/svg+xml", avif: "image/avif",
|
|
47
|
+
mp4: "video/mp4", webm: "video/webm", ogg: "video/ogg", mov: "video/quicktime",
|
|
48
|
+
mp3: "audio/mpeg", wav: "audio/wav", flac: "audio/flac", aac: "audio/aac",
|
|
49
|
+
pdf: "application/pdf",
|
|
50
|
+
txt: "text/plain", csv: "text/csv", html: "text/html", css: "text/css",
|
|
51
|
+
js: "text/javascript", json: "application/json", xml: "application/xml",
|
|
52
|
+
};
|
|
53
|
+
return map[ext] || "application/octet-stream";
|
|
54
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { FileRouteConfig, FileInfo, FileType } from "./types.js";
|
|
2
|
+
import { UploadboxError } from "./errors.js";
|
|
3
|
+
import { parseFileSize } from "./utils.js";
|
|
4
|
+
import { DEFAULT_MAX_FILE_SIZES, getFileTypeFromMime, FILE_TYPE_MIME_MAP } from "./file-types.js";
|
|
5
|
+
|
|
6
|
+
export function validateFiles(files: FileInfo[], config: FileRouteConfig): void {
|
|
7
|
+
const allowedTypes = Object.keys(config) as FileType[];
|
|
8
|
+
|
|
9
|
+
// Compute total max/min file count across all types
|
|
10
|
+
let totalMaxCount = 0;
|
|
11
|
+
let totalMinCount = 0;
|
|
12
|
+
for (const type of allowedTypes) {
|
|
13
|
+
const typeConfig = config[type]!;
|
|
14
|
+
totalMaxCount += typeConfig.maxFileCount ?? 1;
|
|
15
|
+
totalMinCount += typeConfig.minFileCount ?? 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (files.length > totalMaxCount) {
|
|
19
|
+
throw UploadboxError.tooManyFiles(totalMaxCount);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (files.length < totalMinCount) {
|
|
23
|
+
throw UploadboxError.tooFewFiles(totalMinCount);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
const fileType = getFileTypeFromMime(file.type);
|
|
28
|
+
|
|
29
|
+
// If blob is allowed, any type is OK
|
|
30
|
+
if (!allowedTypes.includes("blob")) {
|
|
31
|
+
if (!fileType || !allowedTypes.includes(fileType)) {
|
|
32
|
+
throw UploadboxError.invalidFileType(file.type);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Find the matching config for this file's type
|
|
37
|
+
const matchedType = fileType && allowedTypes.includes(fileType) ? fileType : "blob";
|
|
38
|
+
const typeConfig = config[matchedType];
|
|
39
|
+
if (!typeConfig && !config.blob) {
|
|
40
|
+
throw UploadboxError.invalidFileType(file.type);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const effectiveConfig = typeConfig || config.blob!;
|
|
44
|
+
const maxSize = effectiveConfig.maxFileSize ?? (DEFAULT_MAX_FILE_SIZES[matchedType] as any);
|
|
45
|
+
if (maxSize) {
|
|
46
|
+
const maxBytes = parseFileSize(maxSize);
|
|
47
|
+
if (file.size > maxBytes) {
|
|
48
|
+
throw UploadboxError.fileTooLarge(maxSize);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|