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 { 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 { 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; } }