import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; // S3 Configuration // Uses existing MINIO_* environment variables that are shared across the application export const S3_CONFIG = { // Remove trailing slash from endpoint if present endpoint: (process.env.MINIO_S3_UPLOAD_BUCKET_URL || process.env.S3_ENDPOINT || 'https://dome-api.slm-lab.net').replace(/\/$/, ''), region: process.env.MINIO_AWS_REGION || process.env.S3_REGION || 'us-east-1', // Use MINIO_AWS_S3_UPLOAD_BUCKET_NAME for pages bucket (shared with other parts of the app) bucket: process.env.MINIO_AWS_S3_UPLOAD_BUCKET_NAME || process.env.S3_BUCKET || 'pages', accessKey: process.env.MINIO_ACCESS_KEY || process.env.S3_ACCESS_KEY || '', secretKey: process.env.MINIO_SECRET_KEY || process.env.S3_SECRET_KEY || '' }; // Validate required S3 configuration if (!S3_CONFIG.accessKey || !S3_CONFIG.secretKey) { console.error('⚠️ S3 credentials are missing! Please set MINIO_ACCESS_KEY and MINIO_SECRET_KEY environment variables.'); if (process.env.NODE_ENV === 'production') { throw new Error('S3 credentials are required in production environment'); } } // Initialize S3 client for Minio export const s3Client = new S3Client({ region: S3_CONFIG.region, endpoint: S3_CONFIG.endpoint, credentials: { accessKeyId: S3_CONFIG.accessKey, secretAccessKey: S3_CONFIG.secretKey }, forcePathStyle: true // Required for MinIO }); /** * Upload a file to S3 * Similar implementation to mission-uploads.ts which works correctly with MinIO */ export async function putObject( key: string, content: string | Buffer, contentType?: string ): Promise<{ key: string; url?: string }> { // Convert string to Buffer consistently, exactly like mission-uploads.ts // This ensures the same encoding and buffer handling that works for mission attachments // For empty strings, use an empty buffer instead of Buffer.from('') to avoid MinIO issues let buffer: Buffer; if (typeof content === 'string') { buffer = content.length > 0 ? Buffer.from(content, 'utf-8') : Buffer.alloc(0); } else if (Buffer.isBuffer(content)) { buffer = content; } else { buffer = Buffer.from(content); } // Use the same pattern as mission-uploads.ts: direct s3Client.send with PutObjectCommand await s3Client.send(new PutObjectCommand({ Bucket: S3_CONFIG.bucket, Key: key, Body: buffer, ContentType: contentType || 'text/plain', })); return { key }; } /** * Get object content from S3 */ export async function getObjectContent(key: string): Promise { try { const command = new GetObjectCommand({ Bucket: S3_CONFIG.bucket, Key: key, }); const response = await s3Client.send(command); if (!response.Body) { return null; } const chunks: Uint8Array[] = []; for await (const chunk of response.Body as any) { chunks.push(chunk); } const buffer = Buffer.concat(chunks); return buffer.toString('utf-8'); } catch (error) { console.error('Error getting object content:', error); return null; } } /** * Delete an object from S3 */ export async function deleteObject(key: string): Promise { const command = new DeleteObjectCommand({ Bucket: S3_CONFIG.bucket, Key: key, }); await s3Client.send(command); } /** * List objects for a user in a specific folder */ export async function listUserObjects(userId: string, folder: string): Promise> { // Normalize folder name to lowercase for consistency const normalizedFolder = folder.toLowerCase(); const prefix = `user-${userId}/${normalizedFolder}/`; // Enable debug logging only in development const isDebug = process.env.NODE_ENV === 'development'; if (isDebug) { console.log(`[listUserObjects] Listing objects with prefix: ${prefix} in bucket: ${S3_CONFIG.bucket}`); } const command = new ListObjectsV2Command({ Bucket: S3_CONFIG.bucket, Prefix: prefix, // Don't use Delimiter to get all objects, not just "folders" }); const response = await s3Client.send(command); const objects = response.Contents || []; if (isDebug) { console.log(`[listUserObjects] Found ${objects.length} raw objects for prefix ${prefix}`); } // Filter out: // - Objects that are "folders" (end with /) // - Placeholder files // - Objects that don't match our prefix const filtered = objects .filter(obj => { if (!obj.Key) { return false; } // Exclude folder markers (end with /) if (obj.Key.endsWith('/')) { return false; } // Exclude placeholder files if (obj.Key.includes('.placeholder')) { return false; } // Ensure it matches our prefix if (!obj.Key.startsWith(prefix)) { return false; } // Additional check: ensure it's actually in the folder (not a subfolder) const keyWithoutPrefix = obj.Key.substring(prefix.length); // If there's another / in the remaining path, it's in a subfolder, skip it if (keyWithoutPrefix.includes('/')) { return false; } return true; }) .map(obj => ({ key: obj.Key!, name: obj.Key!.split('/').pop() || obj.Key!, size: obj.Size, lastModified: obj.LastModified })); if (isDebug) { console.log(`[listUserObjects] Returning ${filtered.length} filtered objects`); } return filtered; } /** * Get public URL for an object * Simple URL construction for MinIO/S3 objects */ export function getPublicUrl(filePath: string, bucket?: string): string { if (!filePath) return ''; if (filePath.startsWith('http')) return filePath; // Already a full URL // Remove leading slash if present const cleanPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; // Construct the full URL const endpoint = S3_CONFIG.endpoint?.replace(/\/$/, ''); // Remove trailing slash if present const bucketName = bucket || S3_CONFIG.bucket; // Return original path if no endpoint is configured if (!endpoint) return cleanPath; // Construct and return the full URL return `${endpoint}/${bucketName}/${cleanPath}`; } /** * Create standard folder structure for a user * In S3/MinIO, folders don't need to be explicitly created - they exist as prefixes. * We only create a placeholder file if the folder doesn't exist or is empty. */ export async function createUserFolderStructure(userId: string): Promise { const folders = ['notes', 'diary', 'health', 'contacts']; for (const folder of folders) { try { // Check if folder already exists by listing objects const existingFiles = await listUserObjects(userId, folder); // If folder already has files, skip creating placeholder if (existingFiles.length > 0) { console.log(`Folder user-${userId}/${folder}/ already exists with ${existingFiles.length} file(s), skipping creation`); continue; } // Check if placeholder already exists const placeholderKey = `user-${userId}/${folder}/.placeholder`; const placeholderExists = await getObjectContent(placeholderKey); if (placeholderExists) { console.log(`Folder user-${userId}/${folder}/ already initialized, skipping`); continue; } // Folder is empty and no placeholder exists, create one const placeholderContent = `Folder initialized at ${new Date().toISOString()}`; await putObject(placeholderKey, placeholderContent, 'text/plain'); console.log(`Initialized folder: user-${userId}/${folder}/`); } catch (error) { console.error(`Error initializing folder ${folder} for user ${userId}:`, error); // Continue with other folders even if one fails } } } export async function uploadMissionFile({ missionId, file, type, // 'logo' or 'attachment' }: { missionId: string; file: File; type: 'logo' | 'attachment'; }): Promise<{ success: boolean; data?: any; error?: string }> { const formData = new FormData(); formData.append('missionId', missionId); formData.append('type', type); formData.append('file', file); const res = await fetch('/api/missions/upload', { method: 'POST', body: formData, }); if (!res.ok) { const err = await res.json().catch(() => ({})); return { success: false, error: err.error || 'Upload failed' }; } const data = await res.json(); return { success: true, data }; }