init: port to typescript and bun
This commit is contained in:
203
services/s3-service.ts
Normal file
203
services/s3-service.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// 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<string | null> {
|
||||
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<string[]> {
|
||||
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<boolean> {
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user