153 lines
3.9 KiB
TypeScript
153 lines
3.9 KiB
TypeScript
import { serverConfig } from "@/srv/config";
|
|
import {
|
|
GetObjectCommand,
|
|
S3Client,
|
|
} from "@aws-sdk/client-s3";
|
|
import { Upload } from "@aws-sdk/lib-storage";
|
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
import { createReadStream, statSync } from "node:fs";
|
|
import path from "path";
|
|
|
|
const PRESIGN_EXPIRY_SECONDS = 3600 * 24;
|
|
const S3_REGION = "us-east-1";
|
|
const KILOBYTE = 1024;
|
|
const MEGABYTE = 1024 * KILOBYTE;
|
|
const UPLOAD_LOG_INTERVAL_BYTES = 25 * MEGABYTE;
|
|
const MULTIPART_PART_SIZE = 5 * MEGABYTE;
|
|
|
|
export type UploadProgress = {
|
|
uploadedBytes: number;
|
|
totalBytes: number;
|
|
percent: number;
|
|
};
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes >= MEGABYTE) {
|
|
return `${(bytes / MEGABYTE).toFixed(1)} MB`;
|
|
}
|
|
|
|
if (bytes >= KILOBYTE) {
|
|
return `${(bytes / KILOBYTE).toFixed(1)} KB`;
|
|
}
|
|
|
|
return `${bytes} B`;
|
|
}
|
|
|
|
export class S3Service {
|
|
private client: S3Client;
|
|
private folder: string;
|
|
private folderToStore: string;
|
|
|
|
constructor(folderToStore: string, folder?: string) {
|
|
this.folderToStore = folderToStore;
|
|
this.folder = folder || "";
|
|
this.client = new S3Client({
|
|
credentials: {
|
|
accessKeyId: serverConfig.S3_ACCESS_KEY,
|
|
secretAccessKey: serverConfig.S3_SECRET_KEY,
|
|
},
|
|
endpoint: serverConfig.S3_ENDPOINT_URL,
|
|
forcePathStyle: true,
|
|
region: S3_REGION,
|
|
});
|
|
}
|
|
|
|
async getFileURL(s3Path: string): Promise<string> {
|
|
return getSignedUrl(
|
|
this.client,
|
|
new GetObjectCommand({
|
|
Bucket: serverConfig.S3_BUCKET_NAME,
|
|
Key: s3Path,
|
|
}),
|
|
{ expiresIn: PRESIGN_EXPIRY_SECONDS },
|
|
);
|
|
}
|
|
|
|
getFileName(s3Path: string): string {
|
|
return path.basename(s3Path);
|
|
}
|
|
|
|
async uploadFile(
|
|
localFilePath: string,
|
|
key: string,
|
|
onProgress?: (progress: UploadProgress) => void,
|
|
abortSignal?: AbortSignal,
|
|
): Promise<string> {
|
|
const s3Path = path.join(this.folder, this.folderToStore, key);
|
|
const totalBytes = statSync(localFilePath).size;
|
|
let lastLoggedBytes = 0;
|
|
let lastLoggedPercent = -1;
|
|
|
|
console.log(
|
|
`[s3] Upload starting: ${localFilePath} -> s3://${serverConfig.S3_BUCKET_NAME}/${s3Path} (${formatBytes(totalBytes)})`,
|
|
);
|
|
|
|
const fileStream = createReadStream(localFilePath);
|
|
|
|
const upload = new Upload({
|
|
client: this.client,
|
|
params: {
|
|
Body: fileStream,
|
|
Bucket: serverConfig.S3_BUCKET_NAME,
|
|
Key: s3Path,
|
|
},
|
|
partSize: MULTIPART_PART_SIZE,
|
|
});
|
|
|
|
const onAbort = () => upload.abort();
|
|
if (abortSignal) {
|
|
if (abortSignal.aborted) {
|
|
upload.abort();
|
|
} else {
|
|
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
}
|
|
}
|
|
|
|
upload.on("httpUploadProgress", (progress) => {
|
|
const uploadedBytes = progress.loaded ?? 0;
|
|
const percent = totalBytes > 0 ? uploadedBytes / totalBytes : 1;
|
|
const percentValue = Math.floor(percent * 100);
|
|
|
|
onProgress?.({
|
|
uploadedBytes,
|
|
totalBytes,
|
|
percent: Math.min(percent, 1),
|
|
});
|
|
|
|
if (
|
|
uploadedBytes === totalBytes ||
|
|
uploadedBytes - lastLoggedBytes >= UPLOAD_LOG_INTERVAL_BYTES ||
|
|
percentValue >= lastLoggedPercent + 10
|
|
) {
|
|
lastLoggedBytes = uploadedBytes;
|
|
lastLoggedPercent = percentValue;
|
|
console.log(
|
|
`[s3] Upload progress: ${Math.min(percentValue, 100)}% (${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)})`,
|
|
);
|
|
}
|
|
});
|
|
|
|
try {
|
|
await upload.done();
|
|
} catch (error) {
|
|
console.error(
|
|
`[s3] Upload failed after ${formatBytes(lastLoggedBytes)} sent:`,
|
|
error,
|
|
);
|
|
throw error;
|
|
} finally {
|
|
abortSignal?.removeEventListener("abort", onAbort);
|
|
}
|
|
|
|
if (totalBytes === 0) {
|
|
onProgress?.({ uploadedBytes: 0, totalBytes: 0, percent: 1 });
|
|
}
|
|
|
|
console.log(
|
|
`[s3] Upload completed: s3://${serverConfig.S3_BUCKET_NAME}/${s3Path}`,
|
|
);
|
|
|
|
return s3Path;
|
|
}
|
|
}
|