import fs from "node:fs/promises"; import { dirname, extname, join } from "node:path"; import { DeleteObjectCommand, GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; import sharp from "sharp"; import { env } from "@reactive-resume/env/server"; import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node"; interface StorageWriteInput { key: string; data: Uint8Array; contentType: string; private?: boolean; } interface StorageReadResult { data: Uint8Array; size: number; etag?: string; lastModified?: Date; contentType?: string; } interface StorageService { list(prefix: string): Promise; write(input: StorageWriteInput): Promise; read(key: string): Promise; delete(key: string): Promise; healthcheck(): Promise; } interface StorageHealthResult { status: "healthy" | "unhealthy"; type: "local" | "s3"; message: string; error?: string; } const CONTENT_TYPE_MAP: Record = { ".webp": "image/webp", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".svg": "image/svg+xml", ".pdf": "application/pdf", }; const DEFAULT_CONTENT_TYPE = "application/octet-stream"; const IMAGE_MIME_TYPES = ["image/gif", "image/png", "image/jpeg", "image/webp"]; // Key builders for different upload types function buildPictureKey(userId: string): string { const timestamp = Date.now(); return `uploads/${userId}/pictures/${timestamp}.jpeg`; } function buildScreenshotKey(userId: string, resumeId: string): string { const timestamp = Date.now(); return `uploads/${userId}/screenshots/${resumeId}/${timestamp}.jpeg`; } function buildPdfKey(userId: string, resumeId: string): string { const timestamp = Date.now(); return `uploads/${userId}/pdfs/${resumeId}/${timestamp}.pdf`; } function buildPublicUrl(path: string): string { const normalizedPath = path.startsWith("/") ? path : `/${path}`; const apiPath = normalizedPath.startsWith("/api/") ? normalizedPath : `/api${normalizedPath}`; return new URL(apiPath, env.APP_URL).toString(); } export function inferContentType(filename: string): string { const extension = extname(filename).toLowerCase(); return CONTENT_TYPE_MAP[extension] ?? DEFAULT_CONTENT_TYPE; } export function isImageFile(mimeType: string): boolean { return IMAGE_MIME_TYPES.includes(mimeType); } interface ProcessedImage { data: Uint8Array; contentType: string; } export async function processImageForUpload(file: File): Promise { const fileBuffer = await file.arrayBuffer(); if (env.FLAG_DISABLE_IMAGE_PROCESSING) { return { data: new Uint8Array(fileBuffer), contentType: file.type, }; } const processedBuffer = await sharp(fileBuffer) .resize(800, 800, { fit: "inside", withoutEnlargement: true }) .jpeg({ quality: 80 }) .toBuffer(); return { data: new Uint8Array(processedBuffer), contentType: "image/jpeg", }; } class LocalStorageService implements StorageService { private rootDirectory: string; constructor() { this.rootDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH); } async list(prefix: string): Promise { const fullPath = this.resolvePath(prefix); try { const files = await fs.readdir(fullPath, { recursive: true }); return files.map((file) => join(prefix, file)); } catch (error: unknown) { // If directory doesn't exist, return empty array if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { return []; } throw error; } } async write({ key, data, private: isPrivate }: StorageWriteInput): Promise { if (isPrivate) { throw new Error( "Private storage writes are not supported by the local filesystem backend. Configure S3 to store private attachments.", ); } const fullPath = this.resolvePath(key); await fs.mkdir(dirname(fullPath), { recursive: true }); await fs.writeFile(fullPath, data); } async read(key: string): Promise { const fullPath = this.resolvePath(key); try { const [arrayBuffer, stats] = await Promise.all([fs.readFile(fullPath), fs.stat(fullPath)]); return { data: arrayBuffer, size: stats.size, etag: `"${stats.size}-${stats.mtime.getTime()}"`, lastModified: stats.mtime, contentType: inferContentType(key), }; } catch (error: unknown) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { return null; } throw error; } } async delete(key: string): Promise { const fullPath = this.resolvePath(key); // Check if the path exists and whether it's a file or folder try { const stats = await fs.stat(fullPath); if (stats.isDirectory()) { // Delete the directory and its contents recursively await fs.rm(fullPath, { recursive: true }); return true; } await fs.unlink(fullPath); return true; } catch { // Path does not exist return false; } } async healthcheck(): Promise { try { await fs.mkdir(this.rootDirectory, { recursive: true }); await fs.access(this.rootDirectory, fs.constants.R_OK | fs.constants.W_OK); return { type: "local", status: "healthy", message: "Local filesystem storage is accessible and has read/write permission.", }; } catch (error: unknown) { return { type: "local", status: "unhealthy", message: "Local filesystem storage is not accessible or lacks sufficient permissions.", error: error instanceof Error ? error.message : "Unknown error", }; } } private resolvePath(key: string): string { const normalizedKey = key.replace(/^\/*/, ""); const segments = normalizedKey .split(/[/\\]+/) .filter((segment) => segment.length > 0 && segment !== "." && segment !== ".."); if (segments.length === 0) throw new Error("Invalid storage key"); return join(this.rootDirectory, ...segments); } } class S3StorageService implements StorageService { private readonly bucket: string; private readonly accessKeyId: string; private readonly secretAccessKey: string; private readonly endpoint: string | undefined; private readonly clientPromise: Promise; constructor() { if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY || !env.S3_BUCKET) { throw new Error("S3 credentials are not set"); } this.bucket = env.S3_BUCKET; this.accessKeyId = env.S3_ACCESS_KEY_ID; this.secretAccessKey = env.S3_SECRET_ACCESS_KEY; this.endpoint = env.S3_ENDPOINT; this.clientPromise = this.createClient(); } private async createClient(): Promise { return new S3Client({ region: env.S3_REGION, forcePathStyle: env.S3_FORCE_PATH_STYLE, ...(this.endpoint ? { endpoint: this.endpoint } : {}), credentials: { accessKeyId: this.accessKeyId, secretAccessKey: this.secretAccessKey, }, }); } private async getClient(): Promise { return this.clientPromise; } async list(prefix: string): Promise { const client = await this.getClient(); const command = new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix }); const response = await client.send(command); if (!response.Contents) return []; return response.Contents.map((object) => object.Key ?? ""); } async write({ key, data, contentType, private: isPrivate }: StorageWriteInput): Promise { const client = await this.getClient(); const command = new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: data, ACL: isPrivate ? "private" : "public-read", ContentType: contentType, }); await client.send(command); } async read(key: string): Promise { try { const client = await this.getClient(); const command = new GetObjectCommand({ Bucket: this.bucket, Key: key }); const response = await client.send(command); if (!response.Body) return null; const arrayBuffer = await response.Body.transformToByteArray(); return { data: arrayBuffer, size: response.ContentLength ?? 0, contentType: response.ContentType ?? inferContentType(key), ...(response.ETag !== undefined ? { etag: response.ETag } : {}), ...(response.LastModified !== undefined ? { lastModified: response.LastModified } : {}), }; } catch { return null; } } async delete(keyOrPrefix: string): Promise { const client = await this.getClient(); // Use list to find all matching keys (handles both single file and folder/prefix) const keys = await this.list(keyOrPrefix); if (keys.length === 0) return false; // Delete all matching keys using Promise.allSettled const deleteCommands = keys.map((k) => new DeleteObjectCommand({ Bucket: this.bucket, Key: k })); const results = await Promise.allSettled(deleteCommands.map((c) => client.send(c))); // Return true if at least one deletion succeeded return results.some((r) => r.status === "fulfilled"); } async healthcheck(): Promise { try { const client = await this.getClient(); const putCommand = new PutObjectCommand({ Bucket: this.bucket, Key: "healthcheck", Body: "OK" }); await client.send(putCommand); const deleteCommand = new DeleteObjectCommand({ Bucket: this.bucket, Key: "healthcheck" }); await client.send(deleteCommand); return { type: "s3", status: "healthy", message: "S3 storage is accessible and credentials are valid.", }; } catch (error: unknown) { return { type: "s3", status: "unhealthy", message: "Failed to connect to S3 storage or invalid credentials.", error: error instanceof Error ? error.message : "Unknown error", }; } } } function createStorageService(): StorageService { if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) { return new S3StorageService(); } return new LocalStorageService(); } let cachedService: StorageService | null = null; export function getStorageService(): StorageService { if (cachedService) return cachedService; cachedService = createStorageService(); return cachedService; } // High-level upload types type UploadType = "picture" | "screenshot" | "pdf"; interface UploadFileInput { userId: string; data: Uint8Array; contentType: string; type: UploadType; resumeId?: string; } interface UploadFileResult { url: string; key: string; } export async function uploadFile(input: UploadFileInput): Promise { const storageService = getStorageService(); let key: string; switch (input.type) { case "picture": key = buildPictureKey(input.userId); break; case "screenshot": if (!input.resumeId) throw new Error("resumeId is required for screenshot uploads"); key = buildScreenshotKey(input.userId, input.resumeId); break; case "pdf": if (!input.resumeId) throw new Error("resumeId is required for pdf uploads"); key = buildPdfKey(input.userId, input.resumeId); break; } await storageService.write({ key, data: input.data, contentType: input.contentType, }); return { key, url: buildPublicUrl(key), }; }