// services/s3-service.ts import { S3Client } from "bun"; import { v4 as uuidv4 } from 'uuid'; import { config } from 'dotenv'; import { logger } from '../utils/logger'; import { decodeBase64Image } from '../utils/image-processor'; config(); // S3 configuration const S3_ENDPOINT = process.env.S3_ENDPOINT; const S3_REGION = process.env.S3_REGION; const S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID; const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY; const BUCKET_NAME = process.env.S3_BUCKET_NAME; const PUBLIC_URL_FILE_PREFIX = (process.env.S3_PUBLIC_URL_PREFIX || 'files').replace(/\/$/, ''); // Initialize S3 client let s3Client: S3Client | null = null; if (S3_ACCESS_KEY_ID && S3_SECRET_ACCESS_KEY && BUCKET_NAME) { try { s3Client = new S3Client({ accessKeyId: S3_ACCESS_KEY_ID, secretAccessKey: S3_SECRET_ACCESS_KEY, bucket: BUCKET_NAME, endpoint: S3_ENDPOINT, region: S3_REGION }); logger.info('S3 client initialized successfully.'); } catch (error) { logger.error('Failed to initialize S3 client:', error); } } else { logger.warn('S3 client configuration is incomplete. S3 operations will be disabled.'); } /** * Uploads an image from a base64 string to S3. * @param base64Data - The base64 content (without the data URI prefix) * @param originalFormat - The image format (e.g., 'png', 'jpeg') * @param activityId - The activity ID, used for naming * @returns The public URL of the uploaded image or null on error */ export async function uploadImageFromBase64( base64Data: string, originalFormat: string, activityId: string ): Promise { if (!s3Client) { logger.warn('S3 client not configured. Cannot upload image.'); return null; } if (!base64Data || !originalFormat || !activityId) { logger.error('S3 Upload: Missing base64Data, originalFormat, or activityId'); return null; } try { const imageBuffer = decodeBase64Image(base64Data); const objectKey = `${PUBLIC_URL_FILE_PREFIX}/activity-${activityId}-${uuidv4()}.${originalFormat}`; // Using Bun's S3Client file API const s3File = s3Client.file(objectKey); await s3File.write(imageBuffer, { type: `image/${originalFormat}`, acl: 'public-read' }); const publicUrl = constructS3Url(objectKey); logger.info(`Image uploaded to S3: ${publicUrl}`); return publicUrl; } catch (error) { logger.error(`S3 Upload Error for activity ${activityId}:`, error); return null; } } /** * Lists all objects in the S3 bucket under a specific prefix. * @param prefix - The prefix to filter objects by * @returns A list of object keys */ export async function listS3Objects(prefix: string): Promise { if (!s3Client) { logger.warn('S3 client not configured. Cannot list objects.'); return []; } logger.debug(`Listing objects from S3 with prefix: "${prefix}"`); try { const objectKeys: string[] = []; let isTruncated = true; let startAfter: string | undefined; while (isTruncated) { // Use Bun's list method with pagination const result = await s3Client.list({ prefix, startAfter, maxKeys: 1000 }); if (result.contents) { // Add keys to our array, filtering out "directories" result.contents.forEach(item => { if (item.key && !item.key.endsWith('/')) { objectKeys.push(item.key); } }); // Get the last key for pagination if (result.contents?.length > 0) { startAfter = result.contents[result.contents.length - 1]?.key; } } isTruncated = result.isTruncated || false; // Safety check to prevent infinite loops if (result.contents?.length === 0) { break; } } logger.info(`Listed ${objectKeys.length} object keys from S3 with prefix "${prefix}"`); return objectKeys; } catch (error) { logger.error(`S3 ListObjects Error with prefix "${prefix}":`, error); return []; } } /** * Deletes multiple objects from S3. * @param objectKeysArray - Array of object keys to delete * @returns True if successful or partially successful, false on major error */ export async function deleteS3Objects(objectKeysArray: string[]): Promise { if (!s3Client) { logger.warn('S3 client not configured. Cannot delete objects.'); return false; } if (!objectKeysArray || objectKeysArray.length === 0) { logger.info('No objects to delete from S3.'); return true; } try { // With Bun's S3Client, we need to delete objects one by one // Process in batches of 100 for better performance const BATCH_SIZE = 100; let successCount = 0; let errorCount = 0; for (let i = 0; i < objectKeysArray.length; i += BATCH_SIZE) { const batch = objectKeysArray.slice(i, i + BATCH_SIZE); // Process batch in parallel const results = await Promise.allSettled( batch.map(key => s3Client!.delete(key)) ); // Count successes and failures for (const result of results) { if (result.status === 'fulfilled') { successCount++; } else { errorCount++; logger.error(`Failed to delete object: ${result.reason}`); } } } logger.info(`Deleted ${successCount} objects from S3. Failed: ${errorCount}`); return errorCount === 0; // True if all succeeded } catch (error) { logger.error('S3 DeleteObjects Error:', error); return false; } } /** * Constructs the public S3 URL for an object key. * @param objectKey - The key of the object in S3 * @returns The full public URL */ export function constructS3Url(objectKey: string): string { if (!S3_ENDPOINT || !BUCKET_NAME) { return ''; } // Ensure S3_ENDPOINT does not end with a slash const s3Base = S3_ENDPOINT.replace(/\/$/, ''); // Ensure BUCKET_NAME does not start or end with a slash const bucket = BUCKET_NAME.replace(/^\//, '').replace(/\/$/, ''); // Ensure objectKey does not start with a slash const key = objectKey.replace(/^\//, ''); return `${s3Base}/${bucket}/${key}`; }